From 81711d77750be55a62a927b1c90f0eaf773e0567 Mon Sep 17 00:00:00 2001 From: ks123 Date: Tue, 3 Mar 2020 16:21:53 +0200 Subject: Created file for moderation utils tests + added setUp to this. --- tests/bot/cogs/moderation/test_utils.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 tests/bot/cogs/moderation/test_utils.py diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py new file mode 100644 index 000000000..ed1d1ed59 --- /dev/null +++ b/tests/bot/cogs/moderation/test_utils.py @@ -0,0 +1,12 @@ +import unittest + + +from tests.helpers import MockBot, MockContext + + +class ModerationUtilsTests(unittest.TestCase): + """Tests Moderation utils.""" + + def setUp(self) -> None: + self.bot = MockBot() + self.ctx = MockContext(bot=self.bot) -- cgit v1.2.3 From 154969022cf62bd4a2bab2f7492ded08bb26ffba Mon Sep 17 00:00:00 2001 From: ks123 Date: Tue, 3 Mar 2020 16:32:13 +0200 Subject: (Moderation Utils Tests): Added imports, modified tests class instance and created new params for tests class --- tests/bot/cogs/moderation/test_utils.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index ed1d1ed59..7d47715d4 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -1,12 +1,14 @@ import unittest +from unittest.mock import AsyncMock +from tests.helpers import MockBot, MockContext, MockMember -from tests.helpers import MockBot, MockContext - -class ModerationUtilsTests(unittest.TestCase): +class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): """Tests Moderation utils.""" - def setUp(self) -> None: + def setUp(self): self.bot = MockBot() - self.ctx = MockContext(bot=self.bot) + self.member = MockMember(id=1234) + self.ctx = MockContext(bot=self.bot, author=self.member) + self.bot.api_client.get = AsyncMock() -- cgit v1.2.3 From fa6a0ae59958ce143f6a7acfbd41e477e940fa84 Mon Sep 17 00:00:00 2001 From: ks123 Date: Tue, 3 Mar 2020 17:29:20 +0200 Subject: (Moderation Utils Tests): Created tests for `has_active_infraction` function --- tests/bot/cogs/moderation/test_utils.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 7d47715d4..d25fbfcb5 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -1,6 +1,7 @@ import unittest from unittest.mock import AsyncMock +from bot.cogs.moderation.utils import has_active_infraction from tests.helpers import MockBot, MockContext, MockMember @@ -12,3 +13,26 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): self.member = MockMember(id=1234) self.ctx = MockContext(bot=self.bot, author=self.member) self.bot.api_client.get = AsyncMock() + + async def test_user_has_active_infraction_true(self): + """Test does `has_active_infraction` return that user have active infraction.""" + self.bot.api_client.get.return_value = [{ + "id": 1, + "inserted_at": "2018-11-22T07:24:06.132307Z", + "expires_at": "5018-11-20T15:52:00Z", + "active": True, + "user": 1234, + "actor": 1234, + "type": "ban", + "reason": "Test", + "hidden": False + }] + self.assertTrue(await has_active_infraction(self.ctx, self.member, "ban"), "User should have active infraction") + + async def test_user_has_active_infraction_false(self): + """Test does `has_active_infraction` return that user don't have active infractions.""" + self.bot.api_client.get.return_value = [] + self.assertFalse( + await has_active_infraction(self.ctx, self.member, "ban"), + "User shouldn't have active infraction" + ) -- cgit v1.2.3 From 98f7a3777152b32bfda24f9d5add938479827c85 Mon Sep 17 00:00:00 2001 From: ks123 Date: Wed, 4 Mar 2020 18:15:54 +0200 Subject: (Moderation Utils Tests): Created tests for `notify_infraction` function. --- tests/bot/cogs/moderation/test_utils.py | 93 ++++++++++++++++++++++++++++++++- 1 file changed, 91 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index d25fbfcb5..89f853262 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -1,8 +1,25 @@ import unittest from unittest.mock import AsyncMock -from bot.cogs.moderation.utils import has_active_infraction -from tests.helpers import MockBot, MockContext, MockMember +from discord import Embed + +from bot.cogs.moderation.utils import has_active_infraction, notify_infraction +from bot.constants import Colours, Icons +from tests.helpers import MockBot, MockContext, MockMember, MockUser + +RULES_URL = "https://pythondiscord.com/pages/rules" +APPEAL_EMAIL = "appeals@pythondiscord.com" + +INFRACTION_TITLE = f"Please review our rules over at {RULES_URL}" +INFRACTION_APPEAL_FOOTER = f"To appeal this infraction, send an e-mail to {APPEAL_EMAIL}" +INFRACTION_AUTHOR_NAME = "Infraction information" +INFRACTION_COLOR = Colours.soft_red + +INFRACTION_DESCRIPTION_TEMPLATE = ( + "\n**Type:** {type}\n" + "**Expires:** {expires}\n" + "**Reason:** {reason}\n" +) class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): @@ -11,6 +28,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): def setUp(self): self.bot = MockBot() self.member = MockMember(id=1234) + self.user = MockUser(id=1234) self.ctx = MockContext(bot=self.bot, author=self.member) self.bot.api_client.get = AsyncMock() @@ -36,3 +54,74 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): await has_active_infraction(self.ctx, self.member, "ban"), "User shouldn't have active infraction" ) + + async def test_notify_infraction(self): + """Test does `notify_infraction` create correct embed.""" + test_cases = [ + { + "args": (self.user, "ban", "2020-02-26 09:20 (23 hours and 59 minutes)"), + "expected_output": { + "description": INFRACTION_DESCRIPTION_TEMPLATE.format(**{ + "type": "Ban", + "expires": "2020-02-26 09:20 (23 hours and 59 minutes)", + "reason": "No reason provided." + }), + "icon_url": Icons.token_removed, + "footer": INFRACTION_APPEAL_FOOTER + } + }, + { + "args": (self.user, "warning", None, "Test reason."), + "expected_output": { + "description": INFRACTION_DESCRIPTION_TEMPLATE.format(**{ + "type": "Warning", + "expires": "N/A", + "reason": "Test reason." + }), + "icon_url": Icons.token_removed, + "footer": Embed.Empty + } + }, + { + "args": (self.user, "note", None, None, Icons.defcon_denied), + "expected_output": { + "description": INFRACTION_DESCRIPTION_TEMPLATE.format(**{ + "type": "Note", + "expires": "N/A", + "reason": "No reason provided." + }), + "icon_url": Icons.defcon_denied, + "footer": Embed.Empty + } + }, + { + "args": (self.user, "mute", "2020-02-26 09:20 (23 hours and 59 minutes)", "Test", Icons.defcon_denied), + "expected_output": { + "description": INFRACTION_DESCRIPTION_TEMPLATE.format(**{ + "type": "Mute", + "expires": "2020-02-26 09:20 (23 hours and 59 minutes)", + "reason": "Test" + }), + "icon_url": Icons.defcon_denied, + "footer": INFRACTION_APPEAL_FOOTER + } + } + ] + + for case in test_cases: + args = case["args"] + expected = case["expected_output"] + + with self.subTest(args=case["args"], expected=case["expected_output"]): + await notify_infraction(*args) + + embed: Embed = self.user.send.call_args[1]["embed"] + + self.assertEqual(embed.title, INFRACTION_TITLE) + self.assertEqual(embed.colour.value, INFRACTION_COLOR) + self.assertEqual(embed.url, RULES_URL) + self.assertEqual(embed.author.name, INFRACTION_AUTHOR_NAME) + self.assertEqual(embed.author.url, RULES_URL) + self.assertEqual(embed.author.icon_url, expected["icon_url"]) + self.assertEqual(embed.footer.text, expected["footer"]) + self.assertEqual(embed.description, expected["description"]) -- cgit v1.2.3 From 4a746fc60b6c51e20e1fab92726665092405f93d Mon Sep 17 00:00:00 2001 From: ks123 Date: Wed, 4 Mar 2020 18:38:30 +0200 Subject: (Moderation Utils Tests): Created tests for `notify_pardon` function. --- tests/bot/cogs/moderation/test_utils.py | 41 +++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 89f853262..05e71e695 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock from discord import Embed -from bot.cogs.moderation.utils import has_active_infraction, notify_infraction +from bot.cogs.moderation.utils import has_active_infraction, notify_infraction, notify_pardon from bot.constants import Colours, Icons from tests.helpers import MockBot, MockContext, MockMember, MockUser @@ -21,6 +21,8 @@ INFRACTION_DESCRIPTION_TEMPLATE = ( "**Reason:** {reason}\n" ) +PARDON_COLOR = Colours.soft_green + class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): """Tests Moderation utils.""" @@ -112,7 +114,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): args = case["args"] expected = case["expected_output"] - with self.subTest(args=case["args"], expected=case["expected_output"]): + with self.subTest(args=args, expected=expected): await notify_infraction(*args) embed: Embed = self.user.send.call_args[1]["embed"] @@ -125,3 +127,38 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(embed.author.icon_url, expected["icon_url"]) self.assertEqual(embed.footer.text, expected["footer"]) self.assertEqual(embed.description, expected["description"]) + + async def test_notify_pardon(self): + """Test does `notify_pardon` create correct embed.""" + test_cases = [ + { + "args": (self.user, "Test title", "Example content"), + "expected_output": { + "description": "Example content", + "title": "Test title", + "icon_url": Icons.user_verified + } + }, + { + "args": (self.user, "Test title 1", "Example content 1", Icons.user_update), + "expected_output": { + "description": "Example content 1", + "title": "Test title 1", + "icon_url": Icons.user_update + } + } + ] + + for case in test_cases: + args = case["args"] + expected = case["expected_output"] + + with self.subTest(args=args, expected=expected): + await notify_pardon(*args) + + embed: Embed = self.user.send.call_args[1]["embed"] + + self.assertEqual(embed.description, expected["description"]) + self.assertEqual(embed.colour.value, PARDON_COLOR) + self.assertEqual(embed.author.name, expected["title"]) + self.assertEqual(embed.author.icon_url, expected["icon_url"]) -- cgit v1.2.3 From 615ffaa97cb14d83c7c57e0efd675aae0b58abd1 Mon Sep 17 00:00:00 2001 From: ks123 Date: Wed, 4 Mar 2020 19:10:36 +0200 Subject: (Moderation Utils Tests): Created tests for `post_user` function. --- tests/bot/cogs/moderation/test_utils.py | 60 ++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 05e71e695..c8c1f9e1a 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -3,7 +3,8 @@ from unittest.mock import AsyncMock from discord import Embed -from bot.cogs.moderation.utils import has_active_infraction, notify_infraction, notify_pardon +from bot.api import ResponseCodeError +from bot.cogs.moderation.utils import has_active_infraction, notify_infraction, notify_pardon, post_user from bot.constants import Colours, Icons from tests.helpers import MockBot, MockContext, MockMember, MockUser @@ -162,3 +163,60 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(embed.colour.value, PARDON_COLOR) self.assertEqual(embed.author.name, expected["title"]) self.assertEqual(embed.author.icon_url, expected["icon_url"]) + + async def test_post_user(self): + """Test does `post_user` work correctly.""" + test_cases = [ + { + "args": (self.ctx, self.user), + "post_result": [ + { + "id": 1234, + "avatar": "test", + "name": "Test", + "discriminator": 1234, + "roles": [ + 1234, + 5678 + ], + "in_guild": True + } + ], + "raise_error": False + }, + { + "args": (self.ctx, self.user), + "post_result": [ + { + "id": 1234, + "avatar": "test", + "name": "Test", + "discriminator": 1234, + "roles": [ + 1234, + 5678 + ], + "in_guild": True + } + ], + "raise_error": True + } + ] + + for case in test_cases: + args = case["args"] + expected = case["post_result"] + error = case["raise_error"] + + with self.subTest(args=args, result=expected, error=error): + self.ctx.bot.api_client.post.return_value = expected + + if error: + self.ctx.bot.api_client.post.side_effect = ResponseCodeError(AsyncMock(response_code=400), expected) + + result = await post_user(*args) + + if error: + self.assertIsNone(result) + else: + self.assertEqual(result, expected) -- cgit v1.2.3 From af71a7775d190a11ed92c0d88b52801cdf3804d8 Mon Sep 17 00:00:00 2001 From: ks123 Date: Wed, 4 Mar 2020 19:26:40 +0200 Subject: (Moderation Utils Tests): Created tests for `send_private_embed` function + Fixed errors. --- tests/bot/cogs/moderation/test_utils.py | 47 ++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index c8c1f9e1a..c1cc11724 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -1,10 +1,13 @@ import unittest +from typing import Union from unittest.mock import AsyncMock -from discord import Embed +from discord import Embed, Forbidden, HTTPException, NotFound from bot.api import ResponseCodeError -from bot.cogs.moderation.utils import has_active_infraction, notify_infraction, notify_pardon, post_user +from bot.cogs.moderation.utils import ( + has_active_infraction, notify_infraction, notify_pardon, post_user, send_private_embed +) from bot.constants import Colours, Icons from tests.helpers import MockBot, MockContext, MockMember, MockUser @@ -212,7 +215,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): self.ctx.bot.api_client.post.return_value = expected if error: - self.ctx.bot.api_client.post.side_effect = ResponseCodeError(AsyncMock(response_code=400), expected) + self.ctx.bot.api_client.post.side_effect = ResponseCodeError(AsyncMock(), expected) result = await post_user(*args) @@ -220,3 +223,41 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(result) else: self.assertEqual(result, expected) + + async def test_send_private_embed(self): + """Test does `send_private_embed` return correct value.""" + test_cases = [ + { + "args": (self.user, Embed(title="Test", description="Test val")), + "expected_output": True, + "raised_exception": None + }, + { + "args": (self.user, Embed(title="Test", description="Test val")), + "expected_output": False, + "raised_exception": HTTPException + }, + { + "args": (self.user, Embed(title="Test", description="Test val")), + "expected_output": False, + "raised_exception": Forbidden + }, + { + "args": (self.user, Embed(title="Test", description="Test val")), + "expected_output": False, + "raised_exception": NotFound + } + ] + + for case in test_cases: + args = case["args"] + expected = case["expected_output"] + raised: Union[Forbidden, HTTPException, NotFound, None] = case["raised_exception"] + + with self.subTest(args=args, expected=expected, raised=raised): + if raised: + self.user.send.side_effect = raised(AsyncMock(), AsyncMock()) + + result = await send_private_embed(*args) + + self.assertEqual(result, expected) -- cgit v1.2.3 From 1c7675ba55342e29fa3e3b82cf36a6e321f76bf8 Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 5 Mar 2020 08:31:14 +0200 Subject: (Moderation Utils Tests): Created tests for `post_infraction` function, created __init__.py for moderation tests --- tests/bot/cogs/moderation/__init__.py | 0 tests/bot/cogs/moderation/test_utils.py | 69 ++++++++++++++++++++++++++++++++- 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 tests/bot/cogs/moderation/__init__.py diff --git a/tests/bot/cogs/moderation/__init__.py b/tests/bot/cogs/moderation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index c1cc11724..984a8aa41 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -1,4 +1,5 @@ import unittest +from datetime import datetime from typing import Union from unittest.mock import AsyncMock @@ -6,7 +7,7 @@ from discord import Embed, Forbidden, HTTPException, NotFound from bot.api import ResponseCodeError from bot.cogs.moderation.utils import ( - has_active_infraction, notify_infraction, notify_pardon, post_user, send_private_embed + has_active_infraction, notify_infraction, notify_pardon, post_infraction, post_user, send_private_embed ) from bot.constants import Colours, Icons from tests.helpers import MockBot, MockContext, MockMember, MockUser @@ -261,3 +262,69 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): result = await send_private_embed(*args) self.assertEqual(result, expected) + + async def test_post_infraction(self): + """Test does `post_infraction` return correct value.""" + test_cases = [ + { + "args": (self.ctx, self.member, "ban", "Test Ban"), + "expected_output": [ + { + "id": 1, + "inserted_at": "2018-11-22T07:24:06.132307Z", + "expires_at": "5018-11-20T15:52:00Z", + "active": True, + "user": 1234, + "actor": 1234, + "type": "ban", + "reason": "Test Ban", + "hidden": False + } + ], + "raised_error": None + }, + { + "args": (self.ctx, self.member, "note", "Test Ban"), + "expected_output": None, + "raised_error": ResponseCodeError(AsyncMock(), AsyncMock()) + }, + { + "args": (self.ctx, self.member, "mute", "Test Ban"), + "expected_output": None, + "raised_error": ResponseCodeError(AsyncMock(), {'user': 1234}) + }, + { + "args": (self.ctx, self.member, "ban", "Test Ban", datetime.now()), + "expected_output": [ + { + "id": 1, + "inserted_at": "2018-11-22T07:24:06.132307Z", + "expires_at": "5018-11-20T15:52:00Z", + "active": True, + "user": 1234, + "actor": 1234, + "type": "ban", + "reason": "Test Ban", + "hidden": False + } + ], + "raised_error": None + }, + ] + + for case in test_cases: + args = case["args"] + expected = case["expected_output"] + raised = case["raised_error"] + + with self.subTest(args=args, expected=expected, raised=raised): + if raised: + self.ctx.bot.api_client.post.side_effect = raised + + self.ctx.bot.api_client.post.return_value = expected + + result = await post_infraction(*args) + + self.assertEqual(result, expected) + + self.ctx.bot.api_client.post.reset_mock(side_effect=True) -- cgit v1.2.3 From 3b3b9f72807fe4c2dfaedb98aa714150b01d46ba Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 5 Mar 2020 08:34:01 +0200 Subject: (Moderation Utils Tests): `send_private_embed` moved exception creating from cases testing to test cases listing, added side_effect resetting. --- tests/bot/cogs/moderation/test_utils.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 984a8aa41..2a07cdc6b 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -236,17 +236,17 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): { "args": (self.user, Embed(title="Test", description="Test val")), "expected_output": False, - "raised_exception": HTTPException + "raised_exception": HTTPException(AsyncMock(), AsyncMock()) }, { "args": (self.user, Embed(title="Test", description="Test val")), "expected_output": False, - "raised_exception": Forbidden + "raised_exception": Forbidden(AsyncMock(), AsyncMock()) }, { "args": (self.user, Embed(title="Test", description="Test val")), "expected_output": False, - "raised_exception": NotFound + "raised_exception": NotFound(AsyncMock(), AsyncMock()) } ] @@ -257,12 +257,14 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): with self.subTest(args=args, expected=expected, raised=raised): if raised: - self.user.send.side_effect = raised(AsyncMock(), AsyncMock()) + self.user.send.side_effect = raised result = await send_private_embed(*args) self.assertEqual(result, expected) + self.user.send.reset_mock(side_effect=True) + async def test_post_infraction(self): """Test does `post_infraction` return correct value.""" test_cases = [ -- cgit v1.2.3 From 30e090be63c96b5844087c979a37a321ccd170df Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 5 Mar 2020 08:43:18 +0200 Subject: (Moderation Utils Tests): Moved `has_active_infraction` tests to one test. --- tests/bot/cogs/moderation/test_utils.py | 57 ++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 2a07cdc6b..18794136c 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -39,28 +39,41 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): self.ctx = MockContext(bot=self.bot, author=self.member) self.bot.api_client.get = AsyncMock() - async def test_user_has_active_infraction_true(self): - """Test does `has_active_infraction` return that user have active infraction.""" - self.bot.api_client.get.return_value = [{ - "id": 1, - "inserted_at": "2018-11-22T07:24:06.132307Z", - "expires_at": "5018-11-20T15:52:00Z", - "active": True, - "user": 1234, - "actor": 1234, - "type": "ban", - "reason": "Test", - "hidden": False - }] - self.assertTrue(await has_active_infraction(self.ctx, self.member, "ban"), "User should have active infraction") - - async def test_user_has_active_infraction_false(self): - """Test does `has_active_infraction` return that user don't have active infractions.""" - self.bot.api_client.get.return_value = [] - self.assertFalse( - await has_active_infraction(self.ctx, self.member, "ban"), - "User shouldn't have active infraction" - ) + async def test_user_has_active_infraction(self): + """Test does `has_active_infraction` return correct value.""" + test_cases = [ + { + "args": (self.ctx, self.member, "ban"), + "get_return_value": [], + "expected_output": False + }, + { + "args": (self.ctx, self.member, "ban"), + "get_return_value": [{ + "id": 1, + "inserted_at": "2018-11-22T07:24:06.132307Z", + "expires_at": "5018-11-20T15:52:00Z", + "active": True, + "user": 1234, + "actor": 1234, + "type": "ban", + "reason": "Test", + "hidden": False + }], + "expected_output": True + } + ] + + for case in test_cases: + args = case["args"] + return_value = case["get_return_value"] + expected = case["expected_output"] + + with self.subTest(args=args, return_value=return_value, expected=expected): + self.bot.api_client.get.return_value = return_value + + result = await has_active_infraction(*args) + self.assertEqual(result, expected) async def test_notify_infraction(self): """Test does `notify_infraction` create correct embed.""" -- cgit v1.2.3 From 9211baaf987277b115bd1e2092f69f29389ac887 Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 5 Mar 2020 15:26:35 +0200 Subject: (Moderation Utils Tests): Removed unnecessary `AsyncMock()` from `__init__` (`self.bot.api_client.get`) --- tests/bot/cogs/moderation/test_utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 18794136c..60d7efa5e 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -37,7 +37,6 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): self.member = MockMember(id=1234) self.user = MockUser(id=1234) self.ctx = MockContext(bot=self.bot, author=self.member) - self.bot.api_client.get = AsyncMock() async def test_user_has_active_infraction(self): """Test does `has_active_infraction` return correct value.""" -- cgit v1.2.3 From 7d988453fe6536df06f47aeef9b5ff36f5d64c39 Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 5 Mar 2020 15:32:25 +0200 Subject: (Moderation Utils Tests): Use `bot.cogs.moderation.utils`'s `RULES_URL` instead creating new one --- tests/bot/cogs/moderation/test_utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 60d7efa5e..ea5aadc59 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -7,12 +7,11 @@ from discord import Embed, Forbidden, HTTPException, NotFound from bot.api import ResponseCodeError from bot.cogs.moderation.utils import ( - has_active_infraction, notify_infraction, notify_pardon, post_infraction, post_user, send_private_embed + RULES_URL, has_active_infraction, notify_infraction, notify_pardon, post_infraction, post_user, send_private_embed ) from bot.constants import Colours, Icons from tests.helpers import MockBot, MockContext, MockMember, MockUser -RULES_URL = "https://pythondiscord.com/pages/rules" APPEAL_EMAIL = "appeals@pythondiscord.com" INFRACTION_TITLE = f"Please review our rules over at {RULES_URL}" -- cgit v1.2.3 From b0ae911f3ecb4c5229c6944f5fed77eced2fc79b Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 5 Mar 2020 15:52:43 +0200 Subject: (Moderation Utils Tests): Added following new assertions to `has_active_infraction` tests: `ctx.send` and `bot.api_client.get` calling. --- tests/bot/cogs/moderation/test_utils.py | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index ea5aadc59..40159f6d9 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -43,7 +43,13 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): { "args": (self.ctx, self.member, "ban"), "get_return_value": [], - "expected_output": False + "expected_output": False, + "get_call": { + "active": "true", + "type": "ban", + "user__id": str(self.member.id) + }, + "send_params": None }, { "args": (self.ctx, self.member, "ban"), @@ -58,7 +64,16 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "reason": "Test", "hidden": False }], - "expected_output": True + "expected_output": True, + "get_call": { + "active": "true", + "type": "ban", + "user__id": str(self.member.id) + }, + "send_params": ( + f":x: According to my records, this user already has a ban infraction. " + f"See infraction **#1**." + ) } ] @@ -66,12 +81,21 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): args = case["args"] return_value = case["get_return_value"] expected = case["expected_output"] + get = case["get_call"] + send_vals = case["send_params"] - with self.subTest(args=args, return_value=return_value, expected=expected): + with self.subTest(args=args, return_value=return_value, expected=expected, get=get, send_vals=send_vals): self.bot.api_client.get.return_value = return_value result = await has_active_infraction(*args) self.assertEqual(result, expected) + self.bot.api_client.get.assert_awaited_once_with("bot/infractions", params=get) + + if result: + self.ctx.send.assert_awaited_once_with(send_vals) + + self.bot.api_client.get.reset_mock() + self.ctx.send.reset_mock() async def test_notify_infraction(self): """Test does `notify_infraction` create correct embed.""" -- cgit v1.2.3 From 05c3a21f34b5cc87ac5e439b0256fffc10682f54 Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 5 Mar 2020 16:39:36 +0200 Subject: (Moderation Utils Tests): Added new assertions to `post_infraction`, added `ctx.send` raising errors, added check for return values and `send_private_embed` call. --- tests/bot/cogs/moderation/test_utils.py | 44 ++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 40159f6d9..609ec2642 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -1,7 +1,7 @@ import unittest from datetime import datetime from typing import Union -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from discord import Embed, Forbidden, HTTPException, NotFound @@ -97,8 +97,9 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): self.bot.api_client.get.reset_mock() self.ctx.send.reset_mock() - async def test_notify_infraction(self): - """Test does `notify_infraction` create correct embed.""" + @patch("bot.cogs.moderation.utils.send_private_embed") + async def test_notify_infraction(self, send_private_embed_mock): + """Test does `notify_infraction` create correct result.""" test_cases = [ { "args": (self.user, "ban", "2020-02-26 09:20 (23 hours and 59 minutes)"), @@ -109,8 +110,10 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "reason": "No reason provided." }), "icon_url": Icons.token_removed, - "footer": INFRACTION_APPEAL_FOOTER - } + "footer": INFRACTION_APPEAL_FOOTER, + }, + "send_result": True, + "send_raise": None }, { "args": (self.user, "warning", None, "Test reason."), @@ -122,7 +125,9 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): }), "icon_url": Icons.token_removed, "footer": Embed.Empty - } + }, + "send_result": False, + "send_raise": Forbidden(AsyncMock(), AsyncMock()) }, { "args": (self.user, "note", None, None, Icons.defcon_denied), @@ -134,7 +139,9 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): }), "icon_url": Icons.defcon_denied, "footer": Embed.Empty - } + }, + "send_result": False, + "send_raise": NotFound(AsyncMock(), AsyncMock()) }, { "args": (self.user, "mute", "2020-02-26 09:20 (23 hours and 59 minutes)", "Test", Icons.defcon_denied), @@ -146,18 +153,28 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): }), "icon_url": Icons.defcon_denied, "footer": INFRACTION_APPEAL_FOOTER - } + }, + "send_result": False, + "send_raise": HTTPException(AsyncMock(), AsyncMock()) } ] for case in test_cases: args = case["args"] expected = case["expected_output"] + send, send_raise = case["send_result"], case["send_raise"] - with self.subTest(args=args, expected=expected): - await notify_infraction(*args) + with self.subTest(args=args, expected=expected, send=send, send_raise=send_raise): + if send_raise: + self.ctx.send.side_effect = send_raise - embed: Embed = self.user.send.call_args[1]["embed"] + send_private_embed_mock.return_value = send + + result = await notify_infraction(*args) + + self.assertEqual(send, result) + + embed = send_private_embed_mock.call_args[0][1] self.assertEqual(embed.title, INFRACTION_TITLE) self.assertEqual(embed.colour.value, INFRACTION_COLOR) @@ -168,6 +185,11 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(embed.footer.text, expected["footer"]) self.assertEqual(embed.description, expected["description"]) + send_private_embed_mock.assert_awaited_once_with(args[0], embed) + + self.ctx.send.reset_mock(side_effect=True) + send_private_embed_mock.reset_mock() + async def test_notify_pardon(self): """Test does `notify_pardon` create correct embed.""" test_cases = [ -- cgit v1.2.3 From 87e5bdb3ff8f591e05e5eb410bbc5139afcc8d23 Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 5 Mar 2020 16:46:15 +0200 Subject: (Moderation Utils Tests): Added new assertions to `notify_pardon`, added `ctx.send` raising errors, added check for return values and `send_private_embed` call. --- tests/bot/cogs/moderation/test_utils.py | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 609ec2642..f38f4557b 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -190,8 +190,9 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): self.ctx.send.reset_mock(side_effect=True) send_private_embed_mock.reset_mock() - async def test_notify_pardon(self): - """Test does `notify_pardon` create correct embed.""" + @patch("bot.cogs.moderation.utils.send_private_embed") + async def test_notify_pardon(self, send_private_embed_mock): + """Test does `notify_pardon` create correct result.""" test_cases = [ { "args": (self.user, "Test title", "Example content"), @@ -199,7 +200,9 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "description": "Example content", "title": "Test title", "icon_url": Icons.user_verified - } + }, + "send_result": True, + "send_raise": None }, { "args": (self.user, "Test title 1", "Example content 1", Icons.user_update), @@ -207,24 +210,39 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "description": "Example content 1", "title": "Test title 1", "icon_url": Icons.user_update - } + }, + "send_result": False, + "send_raise": NotFound(AsyncMock(), AsyncMock()) } ] for case in test_cases: args = case["args"] expected = case["expected_output"] + send, send_raise = case["send_result"], case["send_raise"] with self.subTest(args=args, expected=expected): - await notify_pardon(*args) + if send_raise: + self.ctx.send.side_effect = send_raise - embed: Embed = self.user.send.call_args[1]["embed"] + send_private_embed_mock.return_value = send + + result = await notify_pardon(*args) + + self.assertEqual(send, result) + + embed = send_private_embed_mock.call_args[0][1] self.assertEqual(embed.description, expected["description"]) self.assertEqual(embed.colour.value, PARDON_COLOR) self.assertEqual(embed.author.name, expected["title"]) self.assertEqual(embed.author.icon_url, expected["icon_url"]) + send_private_embed_mock.assert_awaited_once_with(args[0], embed) + + self.ctx.send.reset_mock(side_effect=True) + send_private_embed_mock.reset_mock() + async def test_post_user(self): """Test does `post_user` work correctly.""" test_cases = [ -- cgit v1.2.3 From ded64749940525ea9b1f613560e4e30ec74c0c01 Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 5 Mar 2020 16:54:00 +0200 Subject: (Moderation Utils Tests): Added API POST call assertion to `test_post_user`. --- tests/bot/cogs/moderation/test_utils.py | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index f38f4557b..847ba8465 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -258,10 +258,18 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): 1234, 5678 ], - "in_guild": True + "in_guild": False } ], - "raise_error": False + "raise_error": False, + "payload": { + "avatar_hash": getattr(self.user, "avatar", 0), + "discriminator": int(getattr(self.user, "discriminator", 0)), + "id": self.user.id, + "in_guild": False, + "name": getattr(self.user, "name", "Name unknown"), + "roles": [] + } }, { "args": (self.ctx, self.user), @@ -275,10 +283,18 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): 1234, 5678 ], - "in_guild": True + "in_guild": False } ], - "raise_error": True + "raise_error": True, + "payload": { + "avatar_hash": getattr(self.user, "avatar", 0), + "discriminator": int(getattr(self.user, "discriminator", 0)), + "id": self.user.id, + "in_guild": False, + "name": getattr(self.user, "name", "Name unknown"), + "roles": [] + } } ] @@ -286,8 +302,9 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): args = case["args"] expected = case["post_result"] error = case["raise_error"] + payload = case["payload"] - with self.subTest(args=args, result=expected, error=error): + with self.subTest(args=args, result=expected, error=error, payload=payload): self.ctx.bot.api_client.post.return_value = expected if error: @@ -300,6 +317,8 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): else: self.assertEqual(result, expected) + self.bot.api_client.post.assert_awaited_once_with("bot/users", json=payload) + async def test_send_private_embed(self): """Test does `send_private_embed` return correct value.""" test_cases = [ -- cgit v1.2.3 From d8a00abd3860df68dab1805f213f6467085d78fd Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 5 Mar 2020 16:57:02 +0200 Subject: (Moderation Utils Tests): Added `user.send` call assertion to `test_send_private_embed`. --- tests/bot/cogs/moderation/test_utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 847ba8465..300f0b80d 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -356,6 +356,8 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): result = await send_private_embed(*args) self.assertEqual(result, expected) + if expected: + args[0].send.assert_awaited_once_with(embed=args[1]) self.user.send.reset_mock(side_effect=True) -- cgit v1.2.3 From c1b97d0d6132175910ca8e66d35e908444ef512f Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 5 Mar 2020 19:54:44 +0200 Subject: (Moderation Utils Tests): Added additional assertions to `post_infraction` test. --- tests/bot/cogs/moderation/test_utils.py | 62 ++++++++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 8 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 300f0b80d..c5b8f380f 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -361,8 +361,10 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): self.user.send.reset_mock(side_effect=True) - async def test_post_infraction(self): + @patch("bot.cogs.moderation.utils.post_user") + async def test_post_infraction(self, post_user_mock): """Test does `post_infraction` return correct value.""" + now = datetime.now() test_cases = [ { "args": (self.ctx, self.member, "ban", "Test Ban"), @@ -379,20 +381,44 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "hidden": False } ], - "raised_error": None + "raised_error": None, + "payload": { + "actor": self.ctx.message.author.id, + "hidden": False, + "reason": "Test Ban", + "type": "ban", + "user": self.member.id, + "active": True + } }, { "args": (self.ctx, self.member, "note", "Test Ban"), "expected_output": None, - "raised_error": ResponseCodeError(AsyncMock(), AsyncMock()) + "raised_error": ResponseCodeError(AsyncMock(), AsyncMock()), + "payload": { + "actor": self.ctx.message.author.id, + "hidden": False, + "reason": "Test Ban", + "type": "note", + "user": self.member.id, + "active": True + } }, { "args": (self.ctx, self.member, "mute", "Test Ban"), "expected_output": None, - "raised_error": ResponseCodeError(AsyncMock(), {'user': 1234}) + "raised_error": ResponseCodeError(AsyncMock(status=400), {'user': 1234}), + "payload": { + "actor": self.ctx.message.author.id, + "hidden": False, + "reason": "Test Ban", + "type": "mute", + "user": self.member.id, + "active": True + } }, { - "args": (self.ctx, self.member, "ban", "Test Ban", datetime.now()), + "args": (self.ctx, self.member, "ban", "Test Ban", now, True, False), "expected_output": [ { "id": 1, @@ -406,7 +432,16 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "hidden": False } ], - "raised_error": None + "raised_error": None, + "payload": { + "actor": self.ctx.message.author.id, + "hidden": True, + "reason": "Test Ban", + "type": "ban", + "user": self.member.id, + "active": False, + "expires_at": now.isoformat() + } }, ] @@ -414,15 +449,26 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): args = case["args"] expected = case["expected_output"] raised = case["raised_error"] + payload = case["payload"] + + with self.subTest(args=args, expected=expected, raised=raised, payload=payload): + self.ctx.bot.api_client.post.reset_mock(side_effect=True) + post_user_mock.reset_mock() - with self.subTest(args=args, expected=expected, raised=raised): if raised: self.ctx.bot.api_client.post.side_effect = raised + post_user_mock.return_value = "foo" + self.ctx.bot.api_client.post.return_value = expected result = await post_infraction(*args) self.assertEqual(result, expected) - self.ctx.bot.api_client.post.reset_mock(side_effect=True) + if not raised: + self.bot.api_client.post.assert_awaited_once_with("bot/infractions", json=payload) + + if hasattr(raised, "status") and hasattr(raised, "response_json"): + if raised.status == 400 and "user" in raised.response_json: + post_user_mock.assert_awaited_once_with(args[0], args[1]) -- cgit v1.2.3 From 7dfac36ab5d513fada631e6d473915e05eafe778 Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 5 Mar 2020 20:01:34 +0200 Subject: (Moderation Utils Tests): Fixed errors, added checks before assertions for errors --- tests/bot/cogs/moderation/test_utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index c5b8f380f..7f94f20e8 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -317,7 +317,10 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): else: self.assertEqual(result, expected) - self.bot.api_client.post.assert_awaited_once_with("bot/users", json=payload) + if not error: + self.bot.api_client.post.assert_awaited_once_with("bot/users", json=payload) + + self.bot.api_client.post.reset_mock(side_effect=True) async def test_send_private_embed(self): """Test does `send_private_embed` return correct value.""" -- cgit v1.2.3 From 1e0170481624d4a5ec52058cd4a57dd461439fd4 Mon Sep 17 00:00:00 2001 From: ks123 Date: Sun, 8 Mar 2020 08:02:21 +0200 Subject: (Moderation Utils Tests): Fixed docstrings, added more information to these. --- tests/bot/cogs/moderation/test_utils.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 7f94f20e8..e2345ea37 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -38,7 +38,9 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): self.ctx = MockContext(bot=self.bot, author=self.member) async def test_user_has_active_infraction(self): - """Test does `has_active_infraction` return correct value.""" + """ + Test does `has_active_infraction` return call at least once `ctx.send` API get, check does return correct bool. + """ test_cases = [ { "args": (self.ctx, self.member, "ban"), @@ -99,7 +101,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): @patch("bot.cogs.moderation.utils.send_private_embed") async def test_notify_infraction(self, send_private_embed_mock): - """Test does `notify_infraction` create correct result.""" + """Test does `notify_infraction` create correct embed and return correct boolean.""" test_cases = [ { "args": (self.user, "ban", "2020-02-26 09:20 (23 hours and 59 minutes)"), @@ -192,7 +194,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): @patch("bot.cogs.moderation.utils.send_private_embed") async def test_notify_pardon(self, send_private_embed_mock): - """Test does `notify_pardon` create correct result.""" + """Test does `notify_pardon` create correct embed and return correct bool.""" test_cases = [ { "args": (self.user, "Test title", "Example content"), @@ -244,7 +246,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): send_private_embed_mock.reset_mock() async def test_post_user(self): - """Test does `post_user` work correctly.""" + """Test does `post_user` handle errors and results correctly.""" test_cases = [ { "args": (self.ctx, self.user), @@ -323,7 +325,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): self.bot.api_client.post.reset_mock(side_effect=True) async def test_send_private_embed(self): - """Test does `send_private_embed` return correct value.""" + """Test does `send_private_embed` return correct bool.""" test_cases = [ { "args": (self.user, Embed(title="Test", description="Test val")), @@ -366,7 +368,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): @patch("bot.cogs.moderation.utils.post_user") async def test_post_infraction(self, post_user_mock): - """Test does `post_infraction` return correct value.""" + """Test does `post_infraction` call functions correctly and return `None` or `Dict`.""" now = datetime.now() test_cases = [ { -- cgit v1.2.3 From 87ecf72a328b05c922d1f7c0d6e8a1c86ab405c8 Mon Sep 17 00:00:00 2001 From: ks123 Date: Sun, 8 Mar 2020 08:07:04 +0200 Subject: (Moderation Utils Tests): Removed large `utils` parts import, use import `utils` instead and added `utils` before variables and function that was imported directly before. --- tests/bot/cogs/moderation/test_utils.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index e2345ea37..6722c2d16 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -6,15 +6,13 @@ from unittest.mock import AsyncMock, patch from discord import Embed, Forbidden, HTTPException, NotFound from bot.api import ResponseCodeError -from bot.cogs.moderation.utils import ( - RULES_URL, has_active_infraction, notify_infraction, notify_pardon, post_infraction, post_user, send_private_embed -) +from bot.cogs.moderation import utils from bot.constants import Colours, Icons from tests.helpers import MockBot, MockContext, MockMember, MockUser APPEAL_EMAIL = "appeals@pythondiscord.com" -INFRACTION_TITLE = f"Please review our rules over at {RULES_URL}" +INFRACTION_TITLE = f"Please review our rules over at {utils.RULES_URL}" INFRACTION_APPEAL_FOOTER = f"To appeal this infraction, send an e-mail to {APPEAL_EMAIL}" INFRACTION_AUTHOR_NAME = "Infraction information" INFRACTION_COLOR = Colours.soft_red @@ -89,7 +87,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): with self.subTest(args=args, return_value=return_value, expected=expected, get=get, send_vals=send_vals): self.bot.api_client.get.return_value = return_value - result = await has_active_infraction(*args) + result = await utils.has_active_infraction(*args) self.assertEqual(result, expected) self.bot.api_client.get.assert_awaited_once_with("bot/infractions", params=get) @@ -172,7 +170,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): send_private_embed_mock.return_value = send - result = await notify_infraction(*args) + result = await utils.notify_infraction(*args) self.assertEqual(send, result) @@ -180,9 +178,9 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(embed.title, INFRACTION_TITLE) self.assertEqual(embed.colour.value, INFRACTION_COLOR) - self.assertEqual(embed.url, RULES_URL) + self.assertEqual(embed.url, utils.RULES_URL) self.assertEqual(embed.author.name, INFRACTION_AUTHOR_NAME) - self.assertEqual(embed.author.url, RULES_URL) + self.assertEqual(embed.author.url, utils.RULES_URL) self.assertEqual(embed.author.icon_url, expected["icon_url"]) self.assertEqual(embed.footer.text, expected["footer"]) self.assertEqual(embed.description, expected["description"]) @@ -229,7 +227,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): send_private_embed_mock.return_value = send - result = await notify_pardon(*args) + result = await utils.notify_pardon(*args) self.assertEqual(send, result) @@ -312,7 +310,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): if error: self.ctx.bot.api_client.post.side_effect = ResponseCodeError(AsyncMock(), expected) - result = await post_user(*args) + result = await utils.post_user(*args) if error: self.assertIsNone(result) @@ -358,7 +356,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): if raised: self.user.send.side_effect = raised - result = await send_private_embed(*args) + result = await utils.send_private_embed(*args) self.assertEqual(result, expected) if expected: @@ -467,7 +465,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): self.ctx.bot.api_client.post.return_value = expected - result = await post_infraction(*args) + result = await utils.post_infraction(*args) self.assertEqual(result, expected) -- cgit v1.2.3 From 2faa982722f2e9ed9a0710e0030a6078ecab421a Mon Sep 17 00:00:00 2001 From: ks123 Date: Sun, 8 Mar 2020 08:10:29 +0200 Subject: (Moderation Utils Tests): Hard-coded API get request params for `has_active_infraction` test. --- tests/bot/cogs/moderation/test_utils.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 6722c2d16..56bf6d67e 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -44,11 +44,6 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "args": (self.ctx, self.member, "ban"), "get_return_value": [], "expected_output": False, - "get_call": { - "active": "true", - "type": "ban", - "user__id": str(self.member.id) - }, "send_params": None }, { @@ -65,11 +60,6 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "hidden": False }], "expected_output": True, - "get_call": { - "active": "true", - "type": "ban", - "user__id": str(self.member.id) - }, "send_params": ( f":x: According to my records, this user already has a ban infraction. " f"See infraction **#1**." @@ -81,15 +71,18 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): args = case["args"] return_value = case["get_return_value"] expected = case["expected_output"] - get = case["get_call"] send_vals = case["send_params"] - with self.subTest(args=args, return_value=return_value, expected=expected, get=get, send_vals=send_vals): + with self.subTest(args=args, return_value=return_value, expected=expected, send_vals=send_vals): self.bot.api_client.get.return_value = return_value result = await utils.has_active_infraction(*args) self.assertEqual(result, expected) - self.bot.api_client.get.assert_awaited_once_with("bot/infractions", params=get) + self.bot.api_client.get.assert_awaited_once_with("bot/infractions", params={ + "active": "true", + "type": "ban", + "user__id": str(self.member.id) + }) if result: self.ctx.send.assert_awaited_once_with(send_vals) -- cgit v1.2.3 From 94d3b1303ca55039e19a65043da3abe1ef09280b Mon Sep 17 00:00:00 2001 From: ks123 Date: Sun, 8 Mar 2020 08:29:01 +0200 Subject: (Moderation Utils Tests): Cleaned up `has_active_infraction` test cases, hard-coded args, moved mocks resetting to beginning of subtest, added `ctx.send` check only is infraction nr and type in sent string. --- tests/bot/cogs/moderation/test_utils.py | 41 +++++++++------------------------ 1 file changed, 11 insertions(+), 30 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 56bf6d67e..5868da61f 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -41,43 +41,26 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): """ test_cases = [ { - "args": (self.ctx, self.member, "ban"), "get_return_value": [], "expected_output": False, - "send_params": None + "infraction_nr": None }, { - "args": (self.ctx, self.member, "ban"), - "get_return_value": [{ - "id": 1, - "inserted_at": "2018-11-22T07:24:06.132307Z", - "expires_at": "5018-11-20T15:52:00Z", - "active": True, - "user": 1234, - "actor": 1234, - "type": "ban", - "reason": "Test", - "hidden": False - }], + "get_return_value": [{"id": 1}], "expected_output": True, - "send_params": ( - f":x: According to my records, this user already has a ban infraction. " - f"See infraction **#1**." - ) + "infraction_nr": "**#1**" } ] for case in test_cases: - args = case["args"] - return_value = case["get_return_value"] - expected = case["expected_output"] - send_vals = case["send_params"] + with self.subTest(return_value=case["get_return_value"], expected=case["expected_output"]): + self.bot.api_client.get.reset_mock() + self.ctx.send.reset_mock() - with self.subTest(args=args, return_value=return_value, expected=expected, send_vals=send_vals): - self.bot.api_client.get.return_value = return_value + self.bot.api_client.get.return_value = case["get_return_value"] - result = await utils.has_active_infraction(*args) - self.assertEqual(result, expected) + result = await utils.has_active_infraction(self.ctx, self.member, "ban") + self.assertEqual(result, case["expected_output"]) self.bot.api_client.get.assert_awaited_once_with("bot/infractions", params={ "active": "true", "type": "ban", @@ -85,10 +68,8 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): }) if result: - self.ctx.send.assert_awaited_once_with(send_vals) - - self.bot.api_client.get.reset_mock() - self.ctx.send.reset_mock() + self.assertTrue(case["infraction_nr"] in self.ctx.send.call_args[0][0]) + self.assertTrue("ban" in self.ctx.send.call_args[0][0]) @patch("bot.cogs.moderation.utils.send_private_embed") async def test_notify_infraction(self, send_private_embed_mock): -- cgit v1.2.3 From f4bb6849f8f345ff99f6295e707aa0712af070a7 Mon Sep 17 00:00:00 2001 From: ks123 Date: Sun, 8 Mar 2020 08:36:42 +0200 Subject: (Moderation Utils Tests): Removed `Dict` unpacking in `notify_infraction` test. --- tests/bot/cogs/moderation/test_utils.py | 40 ++++++++++++++++----------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 5868da61f..d6e300c89 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -78,11 +78,11 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): { "args": (self.user, "ban", "2020-02-26 09:20 (23 hours and 59 minutes)"), "expected_output": { - "description": INFRACTION_DESCRIPTION_TEMPLATE.format(**{ - "type": "Ban", - "expires": "2020-02-26 09:20 (23 hours and 59 minutes)", - "reason": "No reason provided." - }), + "description": INFRACTION_DESCRIPTION_TEMPLATE.format( + type="Ban", + expires="2020-02-26 09:20 (23 hours and 59 minutes)", + reason="No reason provided." + ), "icon_url": Icons.token_removed, "footer": INFRACTION_APPEAL_FOOTER, }, @@ -92,11 +92,11 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): { "args": (self.user, "warning", None, "Test reason."), "expected_output": { - "description": INFRACTION_DESCRIPTION_TEMPLATE.format(**{ - "type": "Warning", - "expires": "N/A", - "reason": "Test reason." - }), + "description": INFRACTION_DESCRIPTION_TEMPLATE.format( + type="Warning", + expires="N/A", + reason="Test reason." + ), "icon_url": Icons.token_removed, "footer": Embed.Empty }, @@ -106,11 +106,11 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): { "args": (self.user, "note", None, None, Icons.defcon_denied), "expected_output": { - "description": INFRACTION_DESCRIPTION_TEMPLATE.format(**{ - "type": "Note", - "expires": "N/A", - "reason": "No reason provided." - }), + "description": INFRACTION_DESCRIPTION_TEMPLATE.format( + type="Note", + expires="N/A", + reason="No reason provided." + ), "icon_url": Icons.defcon_denied, "footer": Embed.Empty }, @@ -120,11 +120,11 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): { "args": (self.user, "mute", "2020-02-26 09:20 (23 hours and 59 minutes)", "Test", Icons.defcon_denied), "expected_output": { - "description": INFRACTION_DESCRIPTION_TEMPLATE.format(**{ - "type": "Mute", - "expires": "2020-02-26 09:20 (23 hours and 59 minutes)", - "reason": "Test" - }), + "description": INFRACTION_DESCRIPTION_TEMPLATE.format( + type="Mute", + expires="2020-02-26 09:20 (23 hours and 59 minutes)", + reason="Test" + ), "icon_url": Icons.defcon_denied, "footer": INFRACTION_APPEAL_FOOTER }, -- cgit v1.2.3 From 2870472eae2f62982283d160378ca6953231da4e Mon Sep 17 00:00:00 2001 From: ks123 Date: Sun, 8 Mar 2020 08:39:40 +0200 Subject: (Moderation Utils Tests): Removed unnecessary `ctx.send` `side_effect` and removed these in test cases too in `notify_infraction` test. --- tests/bot/cogs/moderation/test_utils.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index d6e300c89..5ab279391 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -86,8 +86,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "icon_url": Icons.token_removed, "footer": INFRACTION_APPEAL_FOOTER, }, - "send_result": True, - "send_raise": None + "send_result": True }, { "args": (self.user, "warning", None, "Test reason."), @@ -100,8 +99,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "icon_url": Icons.token_removed, "footer": Embed.Empty }, - "send_result": False, - "send_raise": Forbidden(AsyncMock(), AsyncMock()) + "send_result": False }, { "args": (self.user, "note", None, None, Icons.defcon_denied), @@ -114,8 +112,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "icon_url": Icons.defcon_denied, "footer": Embed.Empty }, - "send_result": False, - "send_raise": NotFound(AsyncMock(), AsyncMock()) + "send_result": False }, { "args": (self.user, "mute", "2020-02-26 09:20 (23 hours and 59 minutes)", "Test", Icons.defcon_denied), @@ -128,19 +125,16 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "icon_url": Icons.defcon_denied, "footer": INFRACTION_APPEAL_FOOTER }, - "send_result": False, - "send_raise": HTTPException(AsyncMock(), AsyncMock()) + "send_result": False } ] for case in test_cases: args = case["args"] expected = case["expected_output"] - send, send_raise = case["send_result"], case["send_raise"] + send = case["send_result"] - with self.subTest(args=args, expected=expected, send=send, send_raise=send_raise): - if send_raise: - self.ctx.send.side_effect = send_raise + with self.subTest(args=args, expected=expected, send=send): send_private_embed_mock.return_value = send -- cgit v1.2.3 From f4fff7139ddffa08b12973d69c8f4bd7c47c0224 Mon Sep 17 00:00:00 2001 From: ks123 Date: Sun, 8 Mar 2020 08:41:23 +0200 Subject: (Moderation Utils Tests): Removed unnecessary `ctx.send` mock resetting, moved `send_private_embed` mock reset to beginning of subtest. --- tests/bot/cogs/moderation/test_utils.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 5ab279391..5637ff508 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -135,9 +135,9 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): send = case["send_result"] with self.subTest(args=args, expected=expected, send=send): + send_private_embed_mock.reset_mock() send_private_embed_mock.return_value = send - result = await utils.notify_infraction(*args) self.assertEqual(send, result) @@ -155,9 +155,6 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): send_private_embed_mock.assert_awaited_once_with(args[0], embed) - self.ctx.send.reset_mock(side_effect=True) - send_private_embed_mock.reset_mock() - @patch("bot.cogs.moderation.utils.send_private_embed") async def test_notify_pardon(self, send_private_embed_mock): """Test does `notify_pardon` create correct embed and return correct bool.""" -- cgit v1.2.3 From 8c638bfa67c5e471089fad199bf2c5d64c0be163 Mon Sep 17 00:00:00 2001 From: ks123 Date: Sun, 8 Mar 2020 08:49:32 +0200 Subject: (Moderation Utils Tests): Moved `notify_infraction` embed check from dict to `Embed`. --- tests/bot/cogs/moderation/test_utils.py | 71 ++++++++++++++++++++------------- 1 file changed, 43 insertions(+), 28 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 5637ff508..9844c02f9 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -77,54 +77,76 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): test_cases = [ { "args": (self.user, "ban", "2020-02-26 09:20 (23 hours and 59 minutes)"), - "expected_output": { - "description": INFRACTION_DESCRIPTION_TEMPLATE.format( + "expected_output": Embed( + title=INFRACTION_TITLE, + description=INFRACTION_DESCRIPTION_TEMPLATE.format( type="Ban", expires="2020-02-26 09:20 (23 hours and 59 minutes)", reason="No reason provided." ), - "icon_url": Icons.token_removed, - "footer": INFRACTION_APPEAL_FOOTER, - }, + colour=INFRACTION_COLOR, + url=utils.RULES_URL + ).set_author( + name=INFRACTION_AUTHOR_NAME, + url=utils.RULES_URL, + icon_url=Icons.token_removed + ).set_footer(text=INFRACTION_APPEAL_FOOTER), "send_result": True }, { "args": (self.user, "warning", None, "Test reason."), - "expected_output": { - "description": INFRACTION_DESCRIPTION_TEMPLATE.format( + "expected_output": Embed( + title=INFRACTION_TITLE, + description=INFRACTION_DESCRIPTION_TEMPLATE.format( type="Warning", expires="N/A", reason="Test reason." ), - "icon_url": Icons.token_removed, - "footer": Embed.Empty - }, + colour=INFRACTION_COLOR, + url=utils.RULES_URL + ).set_author( + name=INFRACTION_AUTHOR_NAME, + url=utils.RULES_URL, + icon_url=Icons.token_removed + ), "send_result": False }, { "args": (self.user, "note", None, None, Icons.defcon_denied), - "expected_output": { - "description": INFRACTION_DESCRIPTION_TEMPLATE.format( + "expected_output": Embed( + title=INFRACTION_TITLE, + description=INFRACTION_DESCRIPTION_TEMPLATE.format( type="Note", expires="N/A", reason="No reason provided." ), - "icon_url": Icons.defcon_denied, - "footer": Embed.Empty - }, + colour=INFRACTION_COLOR, + url=utils.RULES_URL + ).set_author( + name=INFRACTION_AUTHOR_NAME, + url=utils.RULES_URL, + icon_url=Icons.defcon_denied + ), "send_result": False }, { "args": (self.user, "mute", "2020-02-26 09:20 (23 hours and 59 minutes)", "Test", Icons.defcon_denied), - "expected_output": { - "description": INFRACTION_DESCRIPTION_TEMPLATE.format( + "expected_output": Embed( + title=INFRACTION_TITLE, + description=INFRACTION_DESCRIPTION_TEMPLATE.format( type="Mute", expires="2020-02-26 09:20 (23 hours and 59 minutes)", reason="Test" ), - "icon_url": Icons.defcon_denied, - "footer": INFRACTION_APPEAL_FOOTER - }, + colour=INFRACTION_COLOR, + url=utils.RULES_URL + ).set_author( + name=INFRACTION_AUTHOR_NAME, + url=utils.RULES_URL, + icon_url=Icons.defcon_denied + ).set_footer( + text=INFRACTION_APPEAL_FOOTER + ), "send_result": False } ] @@ -144,14 +166,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): embed = send_private_embed_mock.call_args[0][1] - self.assertEqual(embed.title, INFRACTION_TITLE) - self.assertEqual(embed.colour.value, INFRACTION_COLOR) - self.assertEqual(embed.url, utils.RULES_URL) - self.assertEqual(embed.author.name, INFRACTION_AUTHOR_NAME) - self.assertEqual(embed.author.url, utils.RULES_URL) - self.assertEqual(embed.author.icon_url, expected["icon_url"]) - self.assertEqual(embed.footer.text, expected["footer"]) - self.assertEqual(embed.description, expected["description"]) + self.assertEqual(embed.to_dict(), expected.to_dict()) send_private_embed_mock.assert_awaited_once_with(args[0], embed) -- cgit v1.2.3 From 4260d3cf60f01f0de55a95290dd038d8a5c079ca Mon Sep 17 00:00:00 2001 From: ks123 Date: Sun, 8 Mar 2020 09:01:18 +0200 Subject: (Moderation Utils Tests): Removed unnecessary `ctx.send` `side_effect` from `notify_pardon`, applied changes to test cases. --- tests/bot/cogs/moderation/test_utils.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 9844c02f9..3616b3cf0 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -181,8 +181,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "title": "Test title", "icon_url": Icons.user_verified }, - "send_result": True, - "send_raise": None + "send_result": True }, { "args": (self.user, "Test title 1", "Example content 1", Icons.user_update), @@ -191,24 +190,21 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "title": "Test title 1", "icon_url": Icons.user_update }, - "send_result": False, - "send_raise": NotFound(AsyncMock(), AsyncMock()) + "send_result": False } ] for case in test_cases: args = case["args"] expected = case["expected_output"] - send, send_raise = case["send_result"], case["send_raise"] + send = case["send_result"] with self.subTest(args=args, expected=expected): - if send_raise: - self.ctx.send.side_effect = send_raise + send_private_embed_mock.reset_mock() send_private_embed_mock.return_value = send result = await utils.notify_pardon(*args) - self.assertEqual(send, result) embed = send_private_embed_mock.call_args[0][1] @@ -220,9 +216,6 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): send_private_embed_mock.assert_awaited_once_with(args[0], embed) - self.ctx.send.reset_mock(side_effect=True) - send_private_embed_mock.reset_mock() - async def test_post_user(self): """Test does `post_user` handle errors and results correctly.""" test_cases = [ -- cgit v1.2.3 From b01c2cd813b2df1f8a12e7b493e5085f4a8b9a6e Mon Sep 17 00:00:00 2001 From: ks123 Date: Sun, 8 Mar 2020 09:05:21 +0200 Subject: (Moderation Utils Tests): Moved `expected_output` from `Dict` to `discord.Embed` in `notify_pardon` test. --- tests/bot/cogs/moderation/test_utils.py | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 3616b3cf0..f8fbee4e2 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -176,20 +176,18 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): test_cases = [ { "args": (self.user, "Test title", "Example content"), - "expected_output": { - "description": "Example content", - "title": "Test title", - "icon_url": Icons.user_verified - }, + "expected_output": Embed( + description="Example content", + colour=PARDON_COLOR + ).set_author(name="Test title", icon_url=Icons.user_verified), "send_result": True }, { "args": (self.user, "Test title 1", "Example content 1", Icons.user_update), - "expected_output": { - "description": "Example content 1", - "title": "Test title 1", - "icon_url": Icons.user_update - }, + "expected_output": Embed( + description="Example content 1", + colour=PARDON_COLOR + ).set_author(name="Test title 1", icon_url=Icons.user_update), "send_result": False } ] @@ -208,11 +206,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(send, result) embed = send_private_embed_mock.call_args[0][1] - - self.assertEqual(embed.description, expected["description"]) - self.assertEqual(embed.colour.value, PARDON_COLOR) - self.assertEqual(embed.author.name, expected["title"]) - self.assertEqual(embed.author.icon_url, expected["icon_url"]) + self.assertEqual(embed.to_dict(), expected.to_dict()) send_private_embed_mock.assert_awaited_once_with(args[0], embed) -- cgit v1.2.3 From fc8b796d3c9d88cff959e8d5035bf62a257a7c9c Mon Sep 17 00:00:00 2001 From: ks123 Date: Sun, 8 Mar 2020 10:33:11 +0200 Subject: (Moderation Utils Tests): Added new check to `post_user` test (`ctx.send` content test), improved test cases. --- tests/bot/cogs/moderation/test_utils.py | 51 +++++++++++---------------------- 1 file changed, 16 insertions(+), 35 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index f8fbee4e2..5e9c627bb 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -212,54 +212,31 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): async def test_post_user(self): """Test does `post_user` handle errors and results correctly.""" + user = MockUser(avatar="abc", discriminator=5678, id=1234, name="Test user") test_cases = [ { - "args": (self.ctx, self.user), - "post_result": [ - { - "id": 1234, - "avatar": "test", - "name": "Test", - "discriminator": 1234, - "roles": [ - 1234, - 5678 - ], - "in_guild": False - } - ], + "args": (self.ctx, user), + "post_result": "bar", "raise_error": False, "payload": { - "avatar_hash": getattr(self.user, "avatar", 0), - "discriminator": int(getattr(self.user, "discriminator", 0)), + "avatar_hash": "abc", + "discriminator": 5678, "id": self.user.id, "in_guild": False, - "name": getattr(self.user, "name", "Name unknown"), + "name": "Test user", "roles": [] } }, { - "args": (self.ctx, self.user), - "post_result": [ - { - "id": 1234, - "avatar": "test", - "name": "Test", - "discriminator": 1234, - "roles": [ - 1234, - 5678 - ], - "in_guild": False - } - ], + "args": (self.ctx, self.member), + "post_result": "foo", "raise_error": True, "payload": { - "avatar_hash": getattr(self.user, "avatar", 0), - "discriminator": int(getattr(self.user, "discriminator", 0)), - "id": self.user.id, + "avatar_hash": 0, + "discriminator": 0, + "id": self.member.id, "in_guild": False, - "name": getattr(self.user, "name", "Name unknown"), + "name": "Name unknown", "roles": [] } } @@ -276,6 +253,8 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): if error: self.ctx.bot.api_client.post.side_effect = ResponseCodeError(AsyncMock(), expected) + err = self.ctx.bot.api_client.post.side_effect + err.status = 400 result = await utils.post_user(*args) @@ -286,6 +265,8 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): if not error: self.bot.api_client.post.assert_awaited_once_with("bot/users", json=payload) + else: + self.assertTrue(str(err.status) in self.ctx.send.call_args[0][0]) self.bot.api_client.post.reset_mock(side_effect=True) -- cgit v1.2.3 From 50582f1eeae46d25653eb545455e720df1d4b162 Mon Sep 17 00:00:00 2001 From: ks123 Date: Sun, 8 Mar 2020 10:37:43 +0200 Subject: (Moderation Utils Tests): Hard-coded args for `send_private_embed` test. --- tests/bot/cogs/moderation/test_utils.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 5e9c627bb..b6bf1a96e 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -1,6 +1,5 @@ import unittest from datetime import datetime -from typing import Union from unittest.mock import AsyncMock, patch from discord import Embed, Forbidden, HTTPException, NotFound @@ -272,43 +271,40 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): async def test_send_private_embed(self): """Test does `send_private_embed` return correct bool.""" + embed = Embed(title="Test", description="Test val") + test_cases = [ { - "args": (self.user, Embed(title="Test", description="Test val")), "expected_output": True, "raised_exception": None }, { - "args": (self.user, Embed(title="Test", description="Test val")), "expected_output": False, "raised_exception": HTTPException(AsyncMock(), AsyncMock()) }, { - "args": (self.user, Embed(title="Test", description="Test val")), "expected_output": False, "raised_exception": Forbidden(AsyncMock(), AsyncMock()) }, { - "args": (self.user, Embed(title="Test", description="Test val")), "expected_output": False, "raised_exception": NotFound(AsyncMock(), AsyncMock()) } ] for case in test_cases: - args = case["args"] expected = case["expected_output"] - raised: Union[Forbidden, HTTPException, NotFound, None] = case["raised_exception"] + raised = case["raised_exception"] - with self.subTest(args=args, expected=expected, raised=raised): + with self.subTest(expected=expected, raised=raised): if raised: self.user.send.side_effect = raised - result = await utils.send_private_embed(*args) + result = await utils.send_private_embed(self.user, embed) self.assertEqual(result, expected) if expected: - args[0].send.assert_awaited_once_with(embed=args[1]) + self.user.send.assert_awaited_once_with(embed=embed) self.user.send.reset_mock(side_effect=True) -- cgit v1.2.3 From 4b211f5278dc5e14871468d05d0414d2b2f7de3c Mon Sep 17 00:00:00 2001 From: ks123 Date: Sun, 8 Mar 2020 10:38:56 +0200 Subject: (Moderation Utils Tests): Removed unnecessary `if` check from `send_private_embed` test --- tests/bot/cogs/moderation/test_utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index b6bf1a96e..7291e42c6 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -297,8 +297,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): raised = case["raised_exception"] with self.subTest(expected=expected, raised=raised): - if raised: - self.user.send.side_effect = raised + self.user.send.side_effect = raised result = await utils.send_private_embed(self.user, embed) -- cgit v1.2.3 From 181971424f4e6c494f8ecb8f75919e27b784dcf5 Mon Sep 17 00:00:00 2001 From: ks123 Date: Sun, 8 Mar 2020 10:40:58 +0200 Subject: (Moderation Utils Tests): Moved mock resetting to beginning of subtest in `post_user` and `send_private_embed` test. --- tests/bot/cogs/moderation/test_utils.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 7291e42c6..d43269b19 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -248,6 +248,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): payload = case["payload"] with self.subTest(args=args, result=expected, error=error, payload=payload): + self.bot.api_client.post.reset_mock(side_effect=True) self.ctx.bot.api_client.post.return_value = expected if error: @@ -267,8 +268,6 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): else: self.assertTrue(str(err.status) in self.ctx.send.call_args[0][0]) - self.bot.api_client.post.reset_mock(side_effect=True) - async def test_send_private_embed(self): """Test does `send_private_embed` return correct bool.""" embed = Embed(title="Test", description="Test val") @@ -297,6 +296,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): raised = case["raised_exception"] with self.subTest(expected=expected, raised=raised): + self.user.send.reset_mock(side_effect=True) self.user.send.side_effect = raised result = await utils.send_private_embed(self.user, embed) @@ -305,8 +305,6 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): if expected: self.user.send.assert_awaited_once_with(embed=embed) - self.user.send.reset_mock(side_effect=True) - @patch("bot.cogs.moderation.utils.post_user") async def test_post_infraction(self, post_user_mock): """Test does `post_infraction` call functions correctly and return `None` or `Dict`.""" -- cgit v1.2.3 From 708e2165ff44b19d31bd6f2d8fdd7d3b408a9ef3 Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 12 Mar 2020 19:51:17 +0200 Subject: (Moderation Utils Tests): Create extra new tests set for `post_infraction` testing, removed old. --- tests/bot/cogs/moderation/test_utils.py | 163 ++++++++++++-------------------- 1 file changed, 60 insertions(+), 103 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index d43269b19..f34a56d50 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -1,6 +1,6 @@ import unittest from datetime import datetime -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from discord import Embed, Forbidden, HTTPException, NotFound @@ -305,114 +305,71 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): if expected: self.user.send.assert_awaited_once_with(embed=embed) - @patch("bot.cogs.moderation.utils.post_user") - async def test_post_infraction(self, post_user_mock): - """Test does `post_infraction` call functions correctly and return `None` or `Dict`.""" - now = datetime.now() - test_cases = [ - { - "args": (self.ctx, self.member, "ban", "Test Ban"), - "expected_output": [ - { - "id": 1, - "inserted_at": "2018-11-22T07:24:06.132307Z", - "expires_at": "5018-11-20T15:52:00Z", - "active": True, - "user": 1234, - "actor": 1234, - "type": "ban", - "reason": "Test Ban", - "hidden": False - } - ], - "raised_error": None, - "payload": { - "actor": self.ctx.message.author.id, - "hidden": False, - "reason": "Test Ban", - "type": "ban", - "user": self.member.id, - "active": True - } - }, - { - "args": (self.ctx, self.member, "note", "Test Ban"), - "expected_output": None, - "raised_error": ResponseCodeError(AsyncMock(), AsyncMock()), - "payload": { - "actor": self.ctx.message.author.id, - "hidden": False, - "reason": "Test Ban", - "type": "note", - "user": self.member.id, - "active": True - } - }, - { - "args": (self.ctx, self.member, "mute", "Test Ban"), - "expected_output": None, - "raised_error": ResponseCodeError(AsyncMock(status=400), {'user': 1234}), - "payload": { - "actor": self.ctx.message.author.id, - "hidden": False, - "reason": "Test Ban", - "type": "mute", - "user": self.member.id, - "active": True - } - }, - { - "args": (self.ctx, self.member, "ban", "Test Ban", now, True, False), - "expected_output": [ - { - "id": 1, - "inserted_at": "2018-11-22T07:24:06.132307Z", - "expires_at": "5018-11-20T15:52:00Z", - "active": True, - "user": 1234, - "actor": 1234, - "type": "ban", - "reason": "Test Ban", - "hidden": False - } - ], - "raised_error": None, - "payload": { - "actor": self.ctx.message.author.id, - "hidden": True, - "reason": "Test Ban", - "type": "ban", - "user": self.member.id, - "active": False, - "expires_at": now.isoformat() - } - }, - ] - for case in test_cases: - args = case["args"] - expected = case["expected_output"] - raised = case["raised_error"] - payload = case["payload"] +class TestPostInfraction(unittest.IsolatedAsyncioTestCase): + """Tests for `post_infraction` function.""" - with self.subTest(args=args, expected=expected, raised=raised, payload=payload): - self.ctx.bot.api_client.post.reset_mock(side_effect=True) - post_user_mock.reset_mock() + def setUp(self): + self.bot = MockBot() + self.member = MockMember(id=1234) + self.user = MockUser(id=1234) + self.ctx = MockContext(bot=self.bot, author=self.member) - if raised: - self.ctx.bot.api_client.post.side_effect = raised + async def test_normal_post_infraction(self): + """Test does `post_infraction` return correct value when no errors raise.""" + now = datetime.now() + payload = { + "actor": self.ctx.message.author.id, + "hidden": True, + "reason": "Test reason", + "type": "ban", + "user": self.member.id, + "active": False, + "expires_at": now.isoformat() + } - post_user_mock.return_value = "foo" + self.ctx.bot.api_client.post.return_value = "foo" + actual = await utils.post_infraction(self.ctx, self.member, "ban", "Test reason", now, True, False) - self.ctx.bot.api_client.post.return_value = expected + self.assertEqual(actual, "foo") + self.ctx.bot.api_client.post.assert_awaited_once_with("bot/infractions", json=payload) - result = await utils.post_infraction(*args) + async def test_unknown_error_post_infraction(self): + """Test does `post_infraction` send info about fail to chat (`ctx.send`).""" + self.ctx.bot.api_client.post.side_effect = ResponseCodeError(AsyncMock(), AsyncMock()) + self.ctx.bot.api_client.post.side_effect.status = 500 - self.assertEqual(result, expected) + actual = await utils.post_infraction(self.ctx, self.user, "ban", "Test reason") + self.assertIsNone(actual) - if not raised: - self.bot.api_client.post.assert_awaited_once_with("bot/infractions", json=payload) + self.assertTrue("500" in self.ctx.send.call_args[0][0]) - if hasattr(raised, "status") and hasattr(raised, "response_json"): - if raised.status == 400 and "user" in raised.response_json: - post_user_mock.assert_awaited_once_with(args[0], args[1]) + @patch("bot.cogs.moderation.utils.post_user") + async def test_user_not_found_none_post_infraction(self, post_user_mock): + """Test does `post_infraction` return `None` correctly due can't create new user.""" + self.bot.api_client.post.side_effect = ResponseCodeError(MagicMock(status=400), {"user": "foo"}) + post_user_mock.return_value = None + + actual = await utils.post_infraction(self.ctx, self.user, "mute", "Test reason") + self.assertIsNone(actual) + post_user_mock.assert_awaited_once_with(self.ctx, self.user) + + @patch("bot.cogs.moderation.utils.post_user") + async def test_first_fail_second_success_user_post_infraction(self, post_user_mock): + """Test does `post_infraction` fail first time and return correct result 2nd time when new user posted.""" + payload = { + "actor": self.ctx.message.author.id, + "hidden": False, + "reason": "Test reason", + "type": "mute", + "user": self.user.id, + "active": True + } + + self.bot.api_client.post.side_effect = [ResponseCodeError(MagicMock(status=400), {"user": "foo"}), "foo"] + post_user_mock.return_value = "bar" + + actual = await utils.post_infraction(self.ctx, self.user, "mute", "Test reason") + self.assertEqual(actual, "foo") + self.bot.api_client.post.assert_awaited_once_with("bot/infractions", json=payload) + post_user_mock.assert_awaited_once_with(self.ctx, self.user) -- cgit v1.2.3 From f793c0772c7b1c5c4edb457fb372e9a57a8200ff Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 12 Mar 2020 19:54:37 +0200 Subject: (Moderation Utils Tests): Added params to variable in `has_active_infraction` test. --- tests/bot/cogs/moderation/test_utils.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index f34a56d50..3432ff595 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -56,15 +56,17 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): self.bot.api_client.get.reset_mock() self.ctx.send.reset_mock() + params = { + "active": "true", + "type": "ban", + "user__id": str(self.member.id) + } + self.bot.api_client.get.return_value = case["get_return_value"] result = await utils.has_active_infraction(self.ctx, self.member, "ban") self.assertEqual(result, case["expected_output"]) - self.bot.api_client.get.assert_awaited_once_with("bot/infractions", params={ - "active": "true", - "type": "ban", - "user__id": str(self.member.id) - }) + self.bot.api_client.get.assert_awaited_once_with("bot/infractions", params=params) if result: self.assertTrue(case["infraction_nr"] in self.ctx.send.call_args[0][0]) -- cgit v1.2.3 From cd1193ec09c5259ff2f2c5906faf20ed788326c9 Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 12 Mar 2020 20:00:24 +0200 Subject: (Moderation Utils Tests): Moved embed generating to test cases loop from test cases listing, added icon to test cases in `notify_pardon` test --- tests/bot/cogs/moderation/test_utils.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 3432ff595..7f5e441b7 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -177,27 +177,27 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): test_cases = [ { "args": (self.user, "Test title", "Example content"), - "expected_output": Embed( - description="Example content", - colour=PARDON_COLOR - ).set_author(name="Test title", icon_url=Icons.user_verified), + "icon": Icons.user_verified, "send_result": True }, { - "args": (self.user, "Test title 1", "Example content 1", Icons.user_update), - "expected_output": Embed( - description="Example content 1", - colour=PARDON_COLOR - ).set_author(name="Test title 1", icon_url=Icons.user_update), + "args": (self.user, "Test title", "Example content", Icons.user_update), + "icon": Icons.user_update, "send_result": False } ] for case in test_cases: args = case["args"] - expected = case["expected_output"] send = case["send_result"] + expected = Embed( + description="Example content", + colour=PARDON_COLOR).set_author( + name="Test title", + icon_url=case["icon"] + ) + with self.subTest(args=args, expected=expected): send_private_embed_mock.reset_mock() -- cgit v1.2.3 From 6376b6a47033c51b80d32e0cf00e6d13ca9d05c3 Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 12 Mar 2020 20:03:00 +0200 Subject: (Moderation Utils Tests): Removed unnecessary symbols from `has_active_infraction` test `infraction_nr` variable and changes this to more unique number. --- tests/bot/cogs/moderation/test_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 7f5e441b7..2f66904d8 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -45,9 +45,9 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "infraction_nr": None }, { - "get_return_value": [{"id": 1}], + "get_return_value": [{"id": 123987}], "expected_output": True, - "infraction_nr": "**#1**" + "infraction_nr": "123987" } ] -- cgit v1.2.3 From f93be96dd484e4b484a3a7e8c1c3b79062b6c386 Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 12 Mar 2020 20:05:03 +0200 Subject: (Moderation Utils Tests): Fixed formatting in `notify_infraction` test. --- tests/bot/cogs/moderation/test_utils.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 2f66904d8..61fb618d4 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -145,9 +145,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): name=INFRACTION_AUTHOR_NAME, url=utils.RULES_URL, icon_url=Icons.defcon_denied - ).set_footer( - text=INFRACTION_APPEAL_FOOTER - ), + ).set_footer(text=INFRACTION_APPEAL_FOOTER), "send_result": False } ] -- cgit v1.2.3 From b70d2fc557bb2bdbc32f905132a0e80272f174a2 Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 12 Mar 2020 20:07:27 +0200 Subject: (Moderation Utils Tests): Hard-coded `self.ctx` argument to `post_user` test, renamed current `args` to `user`, applied this in code. --- tests/bot/cogs/moderation/test_utils.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 61fb618d4..3f721d182 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -214,7 +214,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): user = MockUser(avatar="abc", discriminator=5678, id=1234, name="Test user") test_cases = [ { - "args": (self.ctx, user), + "user": user, "post_result": "bar", "raise_error": False, "payload": { @@ -227,7 +227,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): } }, { - "args": (self.ctx, self.member), + "user": self.member, "post_result": "foo", "raise_error": True, "payload": { @@ -242,12 +242,12 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): ] for case in test_cases: - args = case["args"] + test_user = case["user"] expected = case["post_result"] error = case["raise_error"] payload = case["payload"] - with self.subTest(args=args, result=expected, error=error, payload=payload): + with self.subTest(user=test_user, result=expected, error=error, payload=payload): self.bot.api_client.post.reset_mock(side_effect=True) self.ctx.bot.api_client.post.return_value = expected @@ -256,7 +256,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): err = self.ctx.bot.api_client.post.side_effect err.status = 400 - result = await utils.post_user(*args) + result = await utils.post_user(self.ctx, test_user) if error: self.assertIsNone(result) -- cgit v1.2.3 From 4724b66c4a337b1735f7b08cb60c4cbcb68a6e3c Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 12 Mar 2020 20:13:53 +0200 Subject: (Moderation Utils Tests): Move errors from booleans to actual errors in `post_user` test. --- tests/bot/cogs/moderation/test_utils.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 3f721d182..9afa5ab0b 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -216,7 +216,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): { "user": user, "post_result": "bar", - "raise_error": False, + "raise_error": None, "payload": { "avatar_hash": "abc", "discriminator": 5678, @@ -229,7 +229,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): { "user": self.member, "post_result": "foo", - "raise_error": True, + "raise_error": ResponseCodeError(MagicMock(status=400), "foo"), "payload": { "avatar_hash": 0, "discriminator": 0, @@ -251,10 +251,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): self.bot.api_client.post.reset_mock(side_effect=True) self.ctx.bot.api_client.post.return_value = expected - if error: - self.ctx.bot.api_client.post.side_effect = ResponseCodeError(AsyncMock(), expected) - err = self.ctx.bot.api_client.post.side_effect - err.status = 400 + self.ctx.bot.api_client.post.side_effect = error result = await utils.post_user(self.ctx, test_user) @@ -266,7 +263,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): if not error: self.bot.api_client.post.assert_awaited_once_with("bot/users", json=payload) else: - self.assertTrue(str(err.status) in self.ctx.send.call_args[0][0]) + self.assertTrue(str(error.status) in self.ctx.send.call_args[0][0]) async def test_send_private_embed(self): """Test does `send_private_embed` return correct bool.""" -- cgit v1.2.3 From 35ffc216e62a46aa6ba6fcb7d0717354d176e175 Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 12 Mar 2020 20:15:28 +0200 Subject: (Moderation Utils Tests): Added call check for `ctx.send` in `post_user` test. --- tests/bot/cogs/moderation/test_utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 9afa5ab0b..e0af13a46 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -263,6 +263,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): if not error: self.bot.api_client.post.assert_awaited_once_with("bot/users", json=payload) else: + self.ctx.send.assert_awaited_once() self.assertTrue(str(error.status) in self.ctx.send.call_args[0][0]) async def test_send_private_embed(self): -- cgit v1.2.3 From fe504bd30360df0c18fbfccfb27d2652ac19e9b8 Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 12 Mar 2020 20:19:43 +0200 Subject: (Moderation Utils Tests): Added mock reset due fail. --- tests/bot/cogs/moderation/test_utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index e0af13a46..2cba37e3a 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -355,6 +355,8 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): @patch("bot.cogs.moderation.utils.post_user") async def test_first_fail_second_success_user_post_infraction(self, post_user_mock): """Test does `post_infraction` fail first time and return correct result 2nd time when new user posted.""" + self.bot.api_client.post.reset_mock() + payload = { "actor": self.ctx.message.author.id, "hidden": False, -- cgit v1.2.3 From 5ac2aa48109f16e96195dda60e3a70b65b9562fa Mon Sep 17 00:00:00 2001 From: Karlis S <45097959+ks129@users.noreply.github.com> Date: Thu, 12 Mar 2020 21:10:23 +0200 Subject: (Moderation Utils Tests): Removed `once` from `post_infraction` test due tests failing. --- tests/bot/cogs/moderation/test_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 2cba37e3a..e23585c99 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -371,5 +371,5 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): actual = await utils.post_infraction(self.ctx, self.user, "mute", "Test reason") self.assertEqual(actual, "foo") - self.bot.api_client.post.assert_awaited_once_with("bot/infractions", json=payload) + self.bot.api_client.post.assert_awaited_with("bot/infractions", json=payload) post_user_mock.assert_awaited_once_with(self.ctx, self.user) -- cgit v1.2.3 From dac8e758201e938c5e694efb63b96485fb771274 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 12 Mar 2020 19:22:27 -0700 Subject: Revise docstrings for moderation util tests --- tests/bot/cogs/moderation/test_utils.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index e23585c99..ca951250f 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -36,7 +36,9 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): async def test_user_has_active_infraction(self): """ - Test does `has_active_infraction` return call at least once `ctx.send` API get, check does return correct bool. + Should request the API for active infractions and return `True` if the user has one or `False` otherwise. + + A message should be sent to the context indicating a user already has an infraction, if that's the case. """ test_cases = [ { @@ -74,7 +76,11 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): @patch("bot.cogs.moderation.utils.send_private_embed") async def test_notify_infraction(self, send_private_embed_mock): - """Test does `notify_infraction` create correct embed and return correct boolean.""" + """ + Should send an embed of a certain format as a DM and return `True` if DM successful. + + Appealable infractions should have the appeal message in the embed's footer. + """ test_cases = [ { "args": (self.user, "ban", "2020-02-26 09:20 (23 hours and 59 minutes)"), @@ -171,7 +177,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): @patch("bot.cogs.moderation.utils.send_private_embed") async def test_notify_pardon(self, send_private_embed_mock): - """Test does `notify_pardon` create correct embed and return correct bool.""" + """Should send an embed of a certain format as a DM and return `True` if DM successful.""" test_cases = [ { "args": (self.user, "Test title", "Example content"), @@ -210,7 +216,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): send_private_embed_mock.assert_awaited_once_with(args[0], embed) async def test_post_user(self): - """Test does `post_user` handle errors and results correctly.""" + """Should POST a new user and return the response if successful or otherwise send an error message.""" user = MockUser(avatar="abc", discriminator=5678, id=1234, name="Test user") test_cases = [ { @@ -267,7 +273,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): self.assertTrue(str(error.status) in self.ctx.send.call_args[0][0]) async def test_send_private_embed(self): - """Test does `send_private_embed` return correct bool.""" + """Should DM the user and return `True` on success or `False` on failure.""" embed = Embed(title="Test", description="Test val") test_cases = [ @@ -305,7 +311,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): class TestPostInfraction(unittest.IsolatedAsyncioTestCase): - """Tests for `post_infraction` function.""" + """Tests for the `post_infraction` function.""" def setUp(self): self.bot = MockBot() @@ -314,7 +320,7 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): self.ctx = MockContext(bot=self.bot, author=self.member) async def test_normal_post_infraction(self): - """Test does `post_infraction` return correct value when no errors raise.""" + """Should return response from POST request if there are no errors.""" now = datetime.now() payload = { "actor": self.ctx.message.author.id, @@ -333,7 +339,7 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): self.ctx.bot.api_client.post.assert_awaited_once_with("bot/infractions", json=payload) async def test_unknown_error_post_infraction(self): - """Test does `post_infraction` send info about fail to chat (`ctx.send`).""" + """Should send an error message to chat when a non-400 error occurs.""" self.ctx.bot.api_client.post.side_effect = ResponseCodeError(AsyncMock(), AsyncMock()) self.ctx.bot.api_client.post.side_effect.status = 500 @@ -344,7 +350,7 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): @patch("bot.cogs.moderation.utils.post_user") async def test_user_not_found_none_post_infraction(self, post_user_mock): - """Test does `post_infraction` return `None` correctly due can't create new user.""" + """Should abort and return `None` when a new user fails to be posted.""" self.bot.api_client.post.side_effect = ResponseCodeError(MagicMock(status=400), {"user": "foo"}) post_user_mock.return_value = None @@ -354,7 +360,7 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): @patch("bot.cogs.moderation.utils.post_user") async def test_first_fail_second_success_user_post_infraction(self, post_user_mock): - """Test does `post_infraction` fail first time and return correct result 2nd time when new user posted.""" + """Should post the user if they don't exist, POST infraction again, and return the response if successful.""" self.bot.api_client.post.reset_mock() payload = { -- cgit v1.2.3 From 3043dd1d565943f180a5ae16e46e6daa531466c7 Mon Sep 17 00:00:00 2001 From: Karlis S Date: Fri, 13 Mar 2020 07:25:43 +0000 Subject: (Moderation Utils Tests): Added 2 call check to `post_infraction` test. --- tests/bot/cogs/moderation/test_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index ca951250f..659884d93 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -1,6 +1,6 @@ import unittest from datetime import datetime -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, call, patch from discord import Embed, Forbidden, HTTPException, NotFound @@ -377,5 +377,5 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): actual = await utils.post_infraction(self.ctx, self.user, "mute", "Test reason") self.assertEqual(actual, "foo") - self.bot.api_client.post.assert_awaited_with("bot/infractions", json=payload) + self.bot.api_client.post.assert_has_awaits([call("bot/infractions", json=payload)] * 2) post_user_mock.assert_awaited_once_with(self.ctx, self.user) -- cgit v1.2.3 From 7806e11a017698ff43494a1fa1a908e11bb63e33 Mon Sep 17 00:00:00 2001 From: Karlis S Date: Fri, 13 Mar 2020 07:27:50 +0000 Subject: (Moderation Utils Tests): Removed unnecessary mock resetting. --- tests/bot/cogs/moderation/test_utils.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 659884d93..03f086ba9 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -361,8 +361,6 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): @patch("bot.cogs.moderation.utils.post_user") async def test_first_fail_second_success_user_post_infraction(self, post_user_mock): """Should post the user if they don't exist, POST infraction again, and return the response if successful.""" - self.bot.api_client.post.reset_mock() - payload = { "actor": self.ctx.message.author.id, "hidden": False, -- cgit v1.2.3 From 1052ad4213348ede7ec6e495d32e21b3818153e0 Mon Sep 17 00:00:00 2001 From: Karlis S Date: Fri, 13 Mar 2020 07:31:00 +0000 Subject: (Moderation Utils Tests): Moved `return_value` to `patch` decorator. --- tests/bot/cogs/moderation/test_utils.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 03f086ba9..6702372d6 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -348,17 +348,16 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): self.assertTrue("500" in self.ctx.send.call_args[0][0]) - @patch("bot.cogs.moderation.utils.post_user") + @patch("bot.cogs.moderation.utils.post_user", return_value=None) async def test_user_not_found_none_post_infraction(self, post_user_mock): """Should abort and return `None` when a new user fails to be posted.""" self.bot.api_client.post.side_effect = ResponseCodeError(MagicMock(status=400), {"user": "foo"}) - post_user_mock.return_value = None actual = await utils.post_infraction(self.ctx, self.user, "mute", "Test reason") self.assertIsNone(actual) post_user_mock.assert_awaited_once_with(self.ctx, self.user) - @patch("bot.cogs.moderation.utils.post_user") + @patch("bot.cogs.moderation.utils.post_user", return_value="bar") async def test_first_fail_second_success_user_post_infraction(self, post_user_mock): """Should post the user if they don't exist, POST infraction again, and return the response if successful.""" payload = { @@ -371,7 +370,6 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): } self.bot.api_client.post.side_effect = [ResponseCodeError(MagicMock(status=400), {"user": "foo"}), "foo"] - post_user_mock.return_value = "bar" actual = await utils.post_infraction(self.ctx, self.user, "mute", "Test reason") self.assertEqual(actual, "foo") -- cgit v1.2.3 From 1d486096e20dde3bcf6bc95ced0557840625e84d Mon Sep 17 00:00:00 2001 From: Karlis S Date: Fri, 13 Mar 2020 07:32:21 +0000 Subject: (Moderation Utils Tests): Fixed formatting in `notify_pardon` test. --- tests/bot/cogs/moderation/test_utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 6702372d6..2e4c31836 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -197,7 +197,8 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): expected = Embed( description="Example content", - colour=PARDON_COLOR).set_author( + colour=PARDON_COLOR + ).set_author( name="Test title", icon_url=case["icon"] ) -- cgit v1.2.3 From 897b378a09c1a058f10a92aa23c21e2f737e6819 Mon Sep 17 00:00:00 2001 From: Karlis S Date: Fri, 13 Mar 2020 17:42:39 +0000 Subject: (Moderation Utils Tests): Removed Infraction Color constant. --- tests/bot/cogs/moderation/test_utils.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 2e4c31836..f30e85b12 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -14,7 +14,6 @@ APPEAL_EMAIL = "appeals@pythondiscord.com" INFRACTION_TITLE = f"Please review our rules over at {utils.RULES_URL}" INFRACTION_APPEAL_FOOTER = f"To appeal this infraction, send an e-mail to {APPEAL_EMAIL}" INFRACTION_AUTHOR_NAME = "Infraction information" -INFRACTION_COLOR = Colours.soft_red INFRACTION_DESCRIPTION_TEMPLATE = ( "\n**Type:** {type}\n" @@ -91,7 +90,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): expires="2020-02-26 09:20 (23 hours and 59 minutes)", reason="No reason provided." ), - colour=INFRACTION_COLOR, + colour=Colours.soft_red, url=utils.RULES_URL ).set_author( name=INFRACTION_AUTHOR_NAME, @@ -109,7 +108,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): expires="N/A", reason="Test reason." ), - colour=INFRACTION_COLOR, + colour=Colours.soft_red, url=utils.RULES_URL ).set_author( name=INFRACTION_AUTHOR_NAME, @@ -127,7 +126,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): expires="N/A", reason="No reason provided." ), - colour=INFRACTION_COLOR, + colour=Colours.soft_red, url=utils.RULES_URL ).set_author( name=INFRACTION_AUTHOR_NAME, @@ -145,7 +144,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): expires="2020-02-26 09:20 (23 hours and 59 minutes)", reason="Test" ), - colour=INFRACTION_COLOR, + colour=Colours.soft_red, url=utils.RULES_URL ).set_author( name=INFRACTION_AUTHOR_NAME, -- cgit v1.2.3 From ea2e8bbe320996d4292f958240e231d622d0481d Mon Sep 17 00:00:00 2001 From: Karlis S Date: Fri, 13 Mar 2020 17:45:15 +0000 Subject: (Moderation Utils Tests): Removed Pardon Color constant. --- tests/bot/cogs/moderation/test_utils.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index f30e85b12..52bdb5fbc 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -21,8 +21,6 @@ INFRACTION_DESCRIPTION_TEMPLATE = ( "**Reason:** {reason}\n" ) -PARDON_COLOR = Colours.soft_green - class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): """Tests Moderation utils.""" @@ -196,7 +194,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): expected = Embed( description="Example content", - colour=PARDON_COLOR + colour=Colours.soft_green ).set_author( name="Test title", icon_url=case["icon"] -- cgit v1.2.3 From 99e1239f4734d0ed34688fa77d5094f8984b9209 Mon Sep 17 00:00:00 2001 From: Karlis S Date: Fri, 13 Mar 2020 18:03:40 +0000 Subject: (Mod Utils + Tests): Moved constants from tests to utils, applied change --- bot/cogs/moderation/utils.py | 28 ++++++++++++++++------- tests/bot/cogs/moderation/test_utils.py | 40 ++++++++++++--------------------- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 5052b9048..8121a0af8 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -28,6 +28,18 @@ UserObject = t.Union[discord.Member, discord.User] UserSnowflake = t.Union[UserObject, discord.Object] Infraction = t.Dict[str, t.Union[str, int, bool]] +APPEAL_EMAIL = "appeals@pythondiscord.com" + +INFRACTION_TITLE = f"Please review our rules over at {RULES_URL}" +INFRACTION_APPEAL_FOOTER = f"To appeal this infraction, send an e-mail to {APPEAL_EMAIL}" +INFRACTION_AUTHOR_NAME = "Infraction information" + +INFRACTION_DESCRIPTION_TEMPLATE = ( + "\n**Type:** {type}\n" + "**Expires:** {expires}\n" + "**Reason:** {reason}\n" +) + async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]: """ @@ -132,21 +144,21 @@ async def notify_infraction( log.trace(f"Sending {user} a DM about their {infr_type} infraction.") embed = discord.Embed( - description=textwrap.dedent(f""" - **Type:** {infr_type.capitalize()} - **Expires:** {expires_at or "N/A"} - **Reason:** {reason or "No reason provided."} - """), + description=INFRACTION_DESCRIPTION_TEMPLATE.format( + type=infr_type.capitalize(), + expires=expires_at or "N/A", + reason=reason or "No reason provided." + ), colour=Colours.soft_red ) - embed.set_author(name="Infraction information", icon_url=icon_url, url=RULES_URL) - embed.title = f"Please review our rules over at {RULES_URL}" + embed.set_author(name=INFRACTION_AUTHOR_NAME, icon_url=icon_url, url=RULES_URL) + embed.title = INFRACTION_TITLE embed.url = RULES_URL if infr_type in APPEALABLE_INFRACTIONS: embed.set_footer( - text="To appeal this infraction, send an e-mail to appeals@pythondiscord.com" + text=INFRACTION_APPEAL_FOOTER ) return await send_private_embed(user, embed) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 52bdb5fbc..4f81a2477 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -9,18 +9,6 @@ from bot.cogs.moderation import utils from bot.constants import Colours, Icons from tests.helpers import MockBot, MockContext, MockMember, MockUser -APPEAL_EMAIL = "appeals@pythondiscord.com" - -INFRACTION_TITLE = f"Please review our rules over at {utils.RULES_URL}" -INFRACTION_APPEAL_FOOTER = f"To appeal this infraction, send an e-mail to {APPEAL_EMAIL}" -INFRACTION_AUTHOR_NAME = "Infraction information" - -INFRACTION_DESCRIPTION_TEMPLATE = ( - "\n**Type:** {type}\n" - "**Expires:** {expires}\n" - "**Reason:** {reason}\n" -) - class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): """Tests Moderation utils.""" @@ -82,8 +70,8 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): { "args": (self.user, "ban", "2020-02-26 09:20 (23 hours and 59 minutes)"), "expected_output": Embed( - title=INFRACTION_TITLE, - description=INFRACTION_DESCRIPTION_TEMPLATE.format( + title=utils.INFRACTION_TITLE, + description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( type="Ban", expires="2020-02-26 09:20 (23 hours and 59 minutes)", reason="No reason provided." @@ -91,17 +79,17 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): colour=Colours.soft_red, url=utils.RULES_URL ).set_author( - name=INFRACTION_AUTHOR_NAME, + name=utils.INFRACTION_AUTHOR_NAME, url=utils.RULES_URL, icon_url=Icons.token_removed - ).set_footer(text=INFRACTION_APPEAL_FOOTER), + ).set_footer(text=utils.INFRACTION_APPEAL_FOOTER), "send_result": True }, { "args": (self.user, "warning", None, "Test reason."), "expected_output": Embed( - title=INFRACTION_TITLE, - description=INFRACTION_DESCRIPTION_TEMPLATE.format( + title=utils.INFRACTION_TITLE, + description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( type="Warning", expires="N/A", reason="Test reason." @@ -109,7 +97,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): colour=Colours.soft_red, url=utils.RULES_URL ).set_author( - name=INFRACTION_AUTHOR_NAME, + name=utils.INFRACTION_AUTHOR_NAME, url=utils.RULES_URL, icon_url=Icons.token_removed ), @@ -118,8 +106,8 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): { "args": (self.user, "note", None, None, Icons.defcon_denied), "expected_output": Embed( - title=INFRACTION_TITLE, - description=INFRACTION_DESCRIPTION_TEMPLATE.format( + title=utils.INFRACTION_TITLE, + description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( type="Note", expires="N/A", reason="No reason provided." @@ -127,7 +115,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): colour=Colours.soft_red, url=utils.RULES_URL ).set_author( - name=INFRACTION_AUTHOR_NAME, + name=utils.INFRACTION_AUTHOR_NAME, url=utils.RULES_URL, icon_url=Icons.defcon_denied ), @@ -136,8 +124,8 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): { "args": (self.user, "mute", "2020-02-26 09:20 (23 hours and 59 minutes)", "Test", Icons.defcon_denied), "expected_output": Embed( - title=INFRACTION_TITLE, - description=INFRACTION_DESCRIPTION_TEMPLATE.format( + title=utils.INFRACTION_TITLE, + description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( type="Mute", expires="2020-02-26 09:20 (23 hours and 59 minutes)", reason="Test" @@ -145,10 +133,10 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): colour=Colours.soft_red, url=utils.RULES_URL ).set_author( - name=INFRACTION_AUTHOR_NAME, + name=utils.INFRACTION_AUTHOR_NAME, url=utils.RULES_URL, icon_url=Icons.defcon_denied - ).set_footer(text=INFRACTION_APPEAL_FOOTER), + ).set_footer(text=utils.INFRACTION_APPEAL_FOOTER), "send_result": False } ] -- cgit v1.2.3 From b1098cf64805ec850822d8b9301e1b72153db1f1 Mon Sep 17 00:00:00 2001 From: Karlis S Date: Fri, 13 Mar 2020 18:09:33 +0000 Subject: (Mod Utils): Removed unnecessary `textwrap` import --- bot/cogs/moderation/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 8121a0af8..5fcfeb7c7 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -1,5 +1,4 @@ import logging -import textwrap import typing as t from datetime import datetime -- cgit v1.2.3 From 12cbfe77c9529631ae9038649845e93e41d8d021 Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 2 Apr 2020 10:27:29 +0300 Subject: (Aliases, discord.py 1.3.x Migration): Replaced `ctx.invoke` with direct awaiting command. --- bot/cogs/alias.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 55c7efe65..d7e49b390 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -32,7 +32,7 @@ class Alias (Cog): f'{str(ctx.author)} tried to run the command "{cmd_name}" but lacks permission.' ) - await ctx.invoke(cmd, *args, **kwargs) + await cmd(ctx, *args, **kwargs) @command(name='aliases') async def aliases_command(self, ctx: Context) -> None: -- cgit v1.2.3 From 61a93c18a4b0ec0f40efb9b36fb2f423a9e2193a Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 2 Apr 2020 12:13:37 +0300 Subject: (Snekbox, discord.py 1.3.x Migration): Replaced message full reaction clear with only reeval emoji clear. --- bot/cogs/snekbox.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 315383b12..4ec08886c 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -233,12 +233,12 @@ class Snekbox(Cog): ) code = await self.get_code(new_message) - await ctx.message.clear_reactions() + await ctx.message.clear_reaction(REEVAL_EMOJI) with contextlib.suppress(HTTPException): await response.delete() except asyncio.TimeoutError: - await ctx.message.clear_reactions() + await ctx.message.clear_reaction(REEVAL_EMOJI) return None return code -- cgit v1.2.3 From 88b4c72d8f20d6eb9c9f620ea9ac041a5fa5b9e1 Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 2 Apr 2020 12:57:53 +0300 Subject: (Patches, discord.py 1.3.x Migration): Removed patches due not longer necessary. --- bot/__main__.py | 5 ----- bot/patches/__init__.py | 6 ------ bot/patches/message_edited_at.py | 32 -------------------------------- tests/bot/patches/__init__.py | 0 4 files changed, 43 deletions(-) delete mode 100644 bot/patches/__init__.py delete mode 100644 bot/patches/message_edited_at.py delete mode 100644 tests/bot/patches/__init__.py diff --git a/bot/__main__.py b/bot/__main__.py index 8c3ae02e3..0ae869d0d 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -5,7 +5,6 @@ import sentry_sdk from discord.ext.commands import when_mentioned_or from sentry_sdk.integrations.logging import LoggingIntegration -from bot import patches from bot.bot import Bot from bot.constants import Bot as BotConfig @@ -66,8 +65,4 @@ bot.load_extension("bot.cogs.watchchannels") bot.load_extension("bot.cogs.webhook_remover") bot.load_extension("bot.cogs.wolfram") -# Apply `message_edited_at` patch if discord.py did not yet release a bug fix. -if not hasattr(discord.message.Message, '_handle_edited_timestamp'): - patches.message_edited_at.apply_patch() - bot.run(BotConfig.token) diff --git a/bot/patches/__init__.py b/bot/patches/__init__.py deleted file mode 100644 index 60f6becaa..000000000 --- a/bot/patches/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Subpackage that contains patches for discord.py.""" -from . import message_edited_at - -__all__ = [ - message_edited_at, -] diff --git a/bot/patches/message_edited_at.py b/bot/patches/message_edited_at.py deleted file mode 100644 index a0154f12d..000000000 --- a/bot/patches/message_edited_at.py +++ /dev/null @@ -1,32 +0,0 @@ -""" -# message_edited_at patch. - -Date: 2019-09-16 -Author: Scragly -Added by: Ves Zappa - -Due to a bug in our current version of discord.py (1.2.3), the edited_at timestamp of -`discord.Messages` are not being handled correctly. This patch fixes that until a new -release of discord.py is released (and we've updated to it). -""" -import logging - -from discord import message, utils - -log = logging.getLogger(__name__) - - -def _handle_edited_timestamp(self: message.Message, value: str) -> None: - """Helper function that takes care of parsing the edited timestamp.""" - self._edited_timestamp = utils.parse_time(value) - - -def apply_patch() -> None: - """Applies the `edited_at` patch to the `discord.message.Message` class.""" - message.Message._handle_edited_timestamp = _handle_edited_timestamp - message.Message._HANDLERS['edited_timestamp'] = message.Message._handle_edited_timestamp - log.info("Patch applied: message_edited_at") - - -if __name__ == "__main__": - apply_patch() diff --git a/tests/bot/patches/__init__.py b/tests/bot/patches/__init__.py deleted file mode 100644 index e69de29bb..000000000 -- cgit v1.2.3 From 1a14f4f8deee13055393bc49477b97aec30cb6c9 Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 2 Apr 2020 13:05:22 +0300 Subject: (Off-Topic Names, discord.py 1.3.x Migration): Replaced `asyncio.sleep` with `discord.utils.sleep_until`. --- bot/cogs/off_topic_names.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 81511f99d..29aadedc4 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -1,10 +1,10 @@ -import asyncio import difflib import logging from datetime import datetime, timedelta from discord import Colour, Embed from discord.ext.commands import BadArgument, Cog, Context, Converter, group +from discord.utils import sleep_until from bot.api import ResponseCodeError from bot.bot import Bot @@ -51,8 +51,7 @@ async def update_names(bot: Bot) -> None: # we go past midnight in the `seconds_to_sleep` set below. today_at_midnight = datetime.utcnow().replace(microsecond=0, second=0, minute=0, hour=0) next_midnight = today_at_midnight + timedelta(days=1) - seconds_to_sleep = (next_midnight - datetime.utcnow()).seconds + 1 - await asyncio.sleep(seconds_to_sleep) + await sleep_until(next_midnight) try: channel_0_name, channel_1_name, channel_2_name = await bot.api_client.get( -- cgit v1.2.3 From a4a4b987dd0d042e5d4272782c520c53d804470c Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 2 Apr 2020 13:23:18 +0300 Subject: (Reddit, discord.py 1.3.x Migration): Replaced `asyncio.sleep` with `discord.utils.sleep_until` --- bot/cogs/reddit.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 5a7fa100f..7f0ba98d2 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -10,6 +10,7 @@ from aiohttp import BasicAuth, ClientError from discord import Colour, Embed, TextChannel from discord.ext.commands import Cog, Context, group from discord.ext.tasks import loop +from discord.utils import sleep_until from bot.bot import Bot from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES, Webhooks @@ -200,13 +201,13 @@ class Reddit(Cog): @loop() async def auto_poster_loop(self) -> None: """Post the top 5 posts daily, and the top 5 posts weekly.""" - # once we upgrade to d.py 1.3 this can be removed and the loop can use the `time=datetime.time.min` parameter + # once d.py get support for `time` parameter in loop decorator, + # this can be removed and the loop can use the `time=datetime.time.min` parameter now = datetime.utcnow() tomorrow = now + timedelta(days=1) midnight_tomorrow = tomorrow.replace(hour=0, minute=0, second=0) - seconds_until = (midnight_tomorrow - now).total_seconds() - await asyncio.sleep(seconds_until) + await sleep_until(midnight_tomorrow) await self.bot.wait_until_guild_available() if not self.webhook: -- cgit v1.2.3 From 5064fc717cd119f78af4ea146408c4a02a23f42b Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 2 Apr 2020 13:37:58 +0300 Subject: (Snekbox Fix, discord.py 1.3.x Migration): Applied one reaction clear to tests. --- tests/bot/cogs/test_snekbox.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 1dec0ccaf..1443f7cdc 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -296,7 +296,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): ) ) ctx.message.add_reaction.assert_called_once_with(snekbox.REEVAL_EMOJI) - ctx.message.clear_reactions.assert_called_once() + ctx.message.clear_reaction.assert_called_once_with(snekbox.REEVAL_EMOJI) response.delete.assert_called_once() async def test_continue_eval_does_not_continue(self): @@ -305,7 +305,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): actual = await self.cog.continue_eval(ctx, MockMessage()) self.assertEqual(actual, None) - ctx.message.clear_reactions.assert_called_once() + ctx.message.clear_reaction.assert_called_once_with(snekbox.REEVAL_EMOJI) async def test_get_code(self): """Should return 1st arg (or None) if eval cmd in message, otherwise return full content.""" -- cgit v1.2.3 From 9114c4177f5a6bcb71531c75908e6aba14e4c4ed Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 09:03:28 +0300 Subject: (Tags, discord.py 1.3.x Migration): Replaced with direct function call. --- bot/cogs/tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index a6e5952ff..5aa060f5e 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -143,7 +143,7 @@ class Tags(Cog): @group(name='tags', aliases=('tag', 't'), invoke_without_command=True) async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: """Show all known tags, a single tag, or run a subcommand.""" - await ctx.invoke(self.get_command, tag_name=tag_name) + await self.get_command(ctx, tag_name=tag_name) @tags_group.group(name='search', invoke_without_command=True) async def search_tag_content(self, ctx: Context, *, keywords: str) -> None: -- cgit v1.2.3 From e6455deb5c81efd247cad765fc4edda1ead1fb65 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 09:07:49 +0300 Subject: (Bot Cog, discord.py 1.3.x Migration): Replaced `ctx.invoke` with `ctx.send_help`. --- bot/cogs/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index e897b30ff..963dc4926 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -49,7 +49,7 @@ class BotCog(Cog, name="Bot"): @with_role(Roles.verified) async def botinfo_group(self, ctx: Context) -> None: """Bot informational commands.""" - await ctx.invoke(self.bot.get_command("help"), "bot") + await ctx.send_help("bot") @botinfo_group.command(name='about', aliases=('info',), hidden=True) @with_role(Roles.verified) -- cgit v1.2.3 From 0ff6ffdf1ae1759cd931c7a675f831f770178018 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 15:47:48 +0300 Subject: (Information Tests, discord.py 1.3.x Migration): Moved from `unittest.TestCase` to `unittest.IsolatedAsyncTestCase` in `InformationCogTests`. --- tests/bot/cogs/test_information.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 3c26374f5..d93a1adef 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -14,7 +14,7 @@ from tests import helpers COG_PATH = "bot.cogs.information.Information" -class InformationCogTests(unittest.TestCase): +class InformationCogTests(unittest.IsolatedAsyncioTestCase): """Tests the Information cog.""" @classmethod @@ -30,7 +30,7 @@ class InformationCogTests(unittest.TestCase): self.ctx = helpers.MockContext() self.ctx.author.roles.append(self.moderator_role) - def test_roles_command_command(self): + async def test_roles_command_command(self): """Test if the `role_info` command correctly returns the `moderator_role`.""" self.ctx.guild.roles.append(self.moderator_role) @@ -49,7 +49,7 @@ class InformationCogTests(unittest.TestCase): self.assertEqual(embed.colour, discord.Colour.blurple()) self.assertEqual(embed.description, f"\n`{self.moderator_role.id}` - {self.moderator_role.mention}\n") - def test_role_info_command(self): + async def test_role_info_command(self): """Tests the `role info` command.""" dummy_role = helpers.MockRole( name="Dummy", @@ -99,7 +99,7 @@ class InformationCogTests(unittest.TestCase): self.assertEqual(admin_embed.colour, discord.Colour.red()) @unittest.mock.patch('bot.cogs.information.time_since') - def test_server_info_command(self, time_since_patch): + async def test_server_info_command(self, time_since_patch): time_since_patch.return_value = '2 days ago' self.ctx.guild = helpers.MockGuild( -- cgit v1.2.3 From ae470541d6dede7b1aabe0e90d6125d313f6bd46 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 17:00:55 +0300 Subject: (Information Tests, discord.py 1.3.x Migration): Moved from `unittest.TestCase` to `unittest.IsolatedAsyncTestCase` rest of test case classes. --- tests/bot/cogs/test_information.py | 54 ++++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index d93a1adef..f3cc2ccbd 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -167,7 +167,7 @@ class InformationCogTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(embed.thumbnail.url, 'a-lemon.jpg') -class UserInfractionHelperMethodTests(unittest.TestCase): +class UserInfractionHelperMethodTests(unittest.IsolatedAsyncioTestCase): """Tests for the helper methods of the `!user` command.""" def setUp(self): @@ -177,7 +177,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase): self.cog = information.Information(self.bot) self.member = helpers.MockMember(id=1234) - def test_user_command_helper_method_get_requests(self): + async def test_user_command_helper_method_get_requests(self): """The helper methods should form the correct get requests.""" test_values = ( { @@ -203,7 +203,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase): self.bot.api_client.get.assert_called_once_with(endpoint, params=params) self.bot.api_client.get.reset_mock() - def _method_subtests(self, method, test_values, default_header): + async def _method_subtests(self, method, test_values, default_header): """Helper method that runs the subtests for the different helper methods.""" for test_value in test_values: api_response = test_value["api response"] @@ -217,7 +217,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase): self.assertEqual(expected_output, actual_output) - def test_basic_user_infraction_counts_returns_correct_strings(self): + async def test_basic_user_infraction_counts_returns_correct_strings(self): """The method should correctly list both the total and active number of non-hidden infractions.""" test_values = ( # No infractions means zero counts @@ -248,9 +248,9 @@ class UserInfractionHelperMethodTests(unittest.TestCase): header = ["**Infractions**"] - self._method_subtests(self.cog.basic_user_infraction_counts, test_values, header) + await self._method_subtests(self.cog.basic_user_infraction_counts, test_values, header) - def test_expanded_user_infraction_counts_returns_correct_strings(self): + async def test_expanded_user_infraction_counts_returns_correct_strings(self): """The method should correctly list the total and active number of all infractions split by infraction type.""" test_values = ( { @@ -303,9 +303,9 @@ class UserInfractionHelperMethodTests(unittest.TestCase): header = ["**Infractions**"] - self._method_subtests(self.cog.expanded_user_infraction_counts, test_values, header) + await self._method_subtests(self.cog.expanded_user_infraction_counts, test_values, header) - def test_user_nomination_counts_returns_correct_strings(self): + async def test_user_nomination_counts_returns_correct_strings(self): """The method should list the number of active and historical nominations for the user.""" test_values = ( { @@ -333,12 +333,12 @@ class UserInfractionHelperMethodTests(unittest.TestCase): header = ["**Nominations**"] - self._method_subtests(self.cog.user_nomination_counts, test_values, header) + await self._method_subtests(self.cog.user_nomination_counts, test_values, header) @unittest.mock.patch("bot.cogs.information.time_since", new=unittest.mock.MagicMock(return_value="1 year ago")) @unittest.mock.patch("bot.cogs.information.constants.MODERATION_CHANNELS", new=[50]) -class UserEmbedTests(unittest.TestCase): +class UserEmbedTests(unittest.IsolatedAsyncioTestCase): """Tests for the creation of the `!user` embed.""" def setUp(self): @@ -348,7 +348,7 @@ class UserEmbedTests(unittest.TestCase): self.cog = information.Information(self.bot) @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) - def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self): + async def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self): """The embed should use the string representation of the user if they don't have a nick.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) user = helpers.MockMember() @@ -360,7 +360,7 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.title, "Mr. Hemlock") @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) - def test_create_user_embed_uses_nick_in_title_if_available(self): + async def test_create_user_embed_uses_nick_in_title_if_available(self): """The embed should use the nick if it's available.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) user = helpers.MockMember() @@ -372,7 +372,7 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)") @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) - def test_create_user_embed_ignores_everyone_role(self): + async def test_create_user_embed_ignores_everyone_role(self): """Created `!user` embeds should not contain mention of the @everyone-role.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) admins_role = helpers.MockRole(name='Admins') @@ -388,7 +388,11 @@ class UserEmbedTests(unittest.TestCase): @unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=unittest.mock.AsyncMock) @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=unittest.mock.AsyncMock) - def test_create_user_embed_expanded_information_in_moderation_channels(self, nomination_counts, infraction_counts): + async def test_create_user_embed_expanded_information_in_moderation_channels( + self, + nomination_counts, + infraction_counts + ): """The embed should contain expanded infractions and nomination info in mod channels.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=50)) @@ -423,7 +427,7 @@ class UserEmbedTests(unittest.TestCase): ) @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=unittest.mock.AsyncMock) - def test_create_user_embed_basic_information_outside_of_moderation_channels(self, infraction_counts): + async def test_create_user_embed_basic_information_outside_of_moderation_channels(self, infraction_counts): """The embed should contain only basic infraction data outside of mod channels.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=100)) @@ -454,7 +458,7 @@ class UserEmbedTests(unittest.TestCase): ) @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) - def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self): + async def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self): """The embed should be created with the colour of the top role, if a top role is available.""" ctx = helpers.MockContext() @@ -467,7 +471,7 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.colour, discord.Colour(moderators_role.colour)) @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) - def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self): + async def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self): """The embed should be created with a blurple colour if the user has no assigned roles.""" ctx = helpers.MockContext() @@ -477,7 +481,7 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.colour, discord.Colour.blurple()) @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) - def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self): + async def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self): """The embed thumbnail should be set to the user's avatar in `png` format.""" ctx = helpers.MockContext() @@ -490,7 +494,7 @@ class UserEmbedTests(unittest.TestCase): @unittest.mock.patch("bot.cogs.information.constants") -class UserCommandTests(unittest.TestCase): +class UserCommandTests(unittest.IsolatedAsyncioTestCase): """Tests for the `!user` command.""" def setUp(self): @@ -506,7 +510,7 @@ class UserCommandTests(unittest.TestCase): self.moderator = helpers.MockMember(id=2, name="riffautae", roles=[self.moderator_role]) self.target = helpers.MockMember(id=3, name="__fluzz__") - def test_regular_member_cannot_target_another_member(self, constants): + async def test_regular_member_cannot_target_another_member(self, constants): """A regular user should not be able to use `!user` targeting another user.""" constants.MODERATION_ROLES = [self.moderator_role.id] @@ -516,7 +520,7 @@ class UserCommandTests(unittest.TestCase): ctx.send.assert_called_once_with("You may not use this command on users other than yourself.") - def test_regular_member_cannot_use_command_outside_of_bot_commands(self, constants): + async def test_regular_member_cannot_use_command_outside_of_bot_commands(self, constants): """A regular user should not be able to use this command outside of bot-commands.""" constants.MODERATION_ROLES = [self.moderator_role.id] constants.STAFF_ROLES = [self.moderator_role.id] @@ -529,7 +533,7 @@ class UserCommandTests(unittest.TestCase): asyncio.run(self.cog.user_info.callback(self.cog, ctx)) @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock) - def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants): + async def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants): """A regular user should be allowed to use `!user` targeting themselves in bot-commands.""" constants.STAFF_ROLES = [self.moderator_role.id] constants.Channels.bot_commands = 50 @@ -542,7 +546,7 @@ class UserCommandTests(unittest.TestCase): ctx.send.assert_called_once() @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock) - def test_regular_user_can_explicitly_target_themselves(self, create_embed, constants): + async def test_regular_user_can_explicitly_target_themselves(self, create_embed, constants): """A user should target itself with `!user` when a `user` argument was not provided.""" constants.STAFF_ROLES = [self.moderator_role.id] constants.Channels.bot_commands = 50 @@ -555,7 +559,7 @@ class UserCommandTests(unittest.TestCase): ctx.send.assert_called_once() @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock) - def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants): + async def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants): """Staff members should be able to bypass the bot-commands channel restriction.""" constants.STAFF_ROLES = [self.moderator_role.id] constants.Channels.bot_commands = 50 @@ -568,7 +572,7 @@ class UserCommandTests(unittest.TestCase): ctx.send.assert_called_once() @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock) - def test_moderators_can_target_another_member(self, create_embed, constants): + async def test_moderators_can_target_another_member(self, create_embed, constants): """A moderator should be able to use `!user` targeting another user.""" constants.MODERATION_ROLES = [self.moderator_role.id] constants.STAFF_ROLES = [self.moderator_role.id] -- cgit v1.2.3 From 0917d9d1c15febeb79064065983bf19b0b02b55d Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 17:03:44 +0300 Subject: (Information Tests, discord.py 1.3.x Migration): In `InformationCogTests`, replaced `.callback` calls with direct command awaits. --- tests/bot/cogs/test_information.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index f3cc2ccbd..7137949a0 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -37,9 +37,7 @@ class InformationCogTests(unittest.IsolatedAsyncioTestCase): self.cog.roles_info.can_run = unittest.mock.AsyncMock() self.cog.roles_info.can_run.return_value = True - coroutine = self.cog.roles_info.callback(self.cog, self.ctx) - - self.assertIsNone(asyncio.run(coroutine)) + self.assertIsNone(await self.cog.roles_info(self.ctx)) self.ctx.send.assert_called_once() _, kwargs = self.ctx.send.call_args @@ -74,9 +72,7 @@ class InformationCogTests(unittest.IsolatedAsyncioTestCase): self.cog.role_info.can_run = unittest.mock.AsyncMock() self.cog.role_info.can_run.return_value = True - coroutine = self.cog.role_info.callback(self.cog, self.ctx, dummy_role, admin_role) - - self.assertIsNone(asyncio.run(coroutine)) + self.assertIsNone(await self.cog.role_info(self.ctx, dummy_role, admin_role)) self.assertEqual(self.ctx.send.call_count, 2) @@ -133,8 +129,7 @@ class InformationCogTests(unittest.IsolatedAsyncioTestCase): icon_url='a-lemon.jpg', ) - coroutine = self.cog.server_info.callback(self.cog, self.ctx) - self.assertIsNone(asyncio.run(coroutine)) + self.assertIsNone(await self.cog.server_info(self.ctx)) time_since_patch.assert_called_once_with(self.ctx.guild.created_at, precision='days') _, kwargs = self.ctx.send.call_args -- cgit v1.2.3 From 1eed7d64e953e55cf6a7ed24b247212a2f550fa1 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 17:15:17 +0300 Subject: (Information Tests, discord.py 1.3.x Migration): Fixed `InformationCogTests` command calls. --- tests/bot/cogs/test_information.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 7137949a0..941a049d9 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -37,7 +37,7 @@ class InformationCogTests(unittest.IsolatedAsyncioTestCase): self.cog.roles_info.can_run = unittest.mock.AsyncMock() self.cog.roles_info.can_run.return_value = True - self.assertIsNone(await self.cog.roles_info(self.ctx)) + self.assertIsNone(await self.cog.roles_info(self.cog, self.ctx)) self.ctx.send.assert_called_once() _, kwargs = self.ctx.send.call_args @@ -72,7 +72,7 @@ class InformationCogTests(unittest.IsolatedAsyncioTestCase): self.cog.role_info.can_run = unittest.mock.AsyncMock() self.cog.role_info.can_run.return_value = True - self.assertIsNone(await self.cog.role_info(self.ctx, dummy_role, admin_role)) + self.assertIsNone(await self.cog.role_info(self.cog, self.ctx, dummy_role, admin_role)) self.assertEqual(self.ctx.send.call_count, 2) @@ -129,7 +129,7 @@ class InformationCogTests(unittest.IsolatedAsyncioTestCase): icon_url='a-lemon.jpg', ) - self.assertIsNone(await self.cog.server_info(self.ctx)) + self.assertIsNone(await self.cog.server_info(self.cog, self.ctx)) time_since_patch.assert_called_once_with(self.ctx.guild.created_at, precision='days') _, kwargs = self.ctx.send.call_args -- cgit v1.2.3 From 892777c19d0f5169b53a785deda9be3436b59663 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 17:18:30 +0300 Subject: (Information Tests): Replaced `asyncio.run` with `await` in `UserInfractionHelperMethodTests.` --- tests/bot/cogs/test_information.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 941a049d9..60d49ff5c 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -194,7 +194,7 @@ class UserInfractionHelperMethodTests(unittest.IsolatedAsyncioTestCase): endpoint, params = test_value["expected_args"] with self.subTest(method=helper_method, endpoint=endpoint, params=params): - asyncio.run(helper_method(self.member)) + await helper_method(self.member) self.bot.api_client.get.assert_called_once_with(endpoint, params=params) self.bot.api_client.get.reset_mock() @@ -208,7 +208,7 @@ class UserInfractionHelperMethodTests(unittest.IsolatedAsyncioTestCase): self.bot.api_client.get.return_value = api_response expected_output = "\n".join(default_header + expected_lines) - actual_output = asyncio.run(method(self.member)) + actual_output = await method(self.member) self.assertEqual(expected_output, actual_output) -- cgit v1.2.3 From 7020300ea5a7ae65a78ef56d1a898967f3f5ddba Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 18:11:17 +0300 Subject: (Information Tests): Replaced `asyncio.run` with `await` in `UserEmbedTests`. --- tests/bot/cogs/test_information.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 60d49ff5c..1ea2acd30 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -350,7 +350,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): user.nick = None user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + embed = await self.cog.create_user_embed(ctx, user) self.assertEqual(embed.title, "Mr. Hemlock") @@ -362,7 +362,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): user.nick = "Cat lover" user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + embed = await self.cog.create_user_embed(ctx, user) self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)") @@ -376,7 +376,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): # A `MockMember` has the @Everyone role by default; we add the Admins to that. user = helpers.MockMember(roles=[admins_role], top_role=admins_role) - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + embed = await self.cog.create_user_embed(ctx, user) self.assertIn("&Admins", embed.description) self.assertNotIn("&Everyone", embed.description) @@ -398,7 +398,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): nomination_counts.return_value = "nomination info" user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + embed = await self.cog.create_user_embed(ctx, user) infraction_counts.assert_called_once_with(user) nomination_counts.assert_called_once_with(user) @@ -432,7 +432,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): infraction_counts.return_value = "basic infractions info" user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + embed = await self.cog.create_user_embed(ctx, user) infraction_counts.assert_called_once_with(user) @@ -461,7 +461,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): moderators_role.colour = 100 user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + embed = await self.cog.create_user_embed(ctx, user) self.assertEqual(embed.colour, discord.Colour(moderators_role.colour)) @@ -471,7 +471,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): ctx = helpers.MockContext() user = helpers.MockMember(id=217) - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + embed = await self.cog.create_user_embed(ctx, user) self.assertEqual(embed.colour, discord.Colour.blurple()) @@ -482,7 +482,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): user = helpers.MockMember(id=217) user.avatar_url_as.return_value = "avatar url" - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + embed = await self.cog.create_user_embed(ctx, user) user.avatar_url_as.assert_called_once_with(format="png") self.assertEqual(embed.thumbnail.url, "avatar url") -- cgit v1.2.3 From 5e8093dc65d97e427ca9ac4858dc25103075e10b Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 18:14:15 +0300 Subject: (Information Tests): Replaced `asyncio.run` with `await` in `UserCommandTests`. --- tests/bot/cogs/test_information.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 1ea2acd30..a3f80b1e5 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -1,4 +1,3 @@ -import asyncio import textwrap import unittest import unittest.mock @@ -511,7 +510,7 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase): ctx = helpers.MockContext(author=self.author) - asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target)) + await self.cog.user_info(self.cog, ctx, self.target) ctx.send.assert_called_once_with("You may not use this command on users other than yourself.") @@ -525,7 +524,7 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase): msg = "Sorry, but you may only use this command within <#50>." with self.assertRaises(InChannelCheckFailure, msg=msg): - asyncio.run(self.cog.user_info.callback(self.cog, ctx)) + await self.cog.user_info(self.cog, ctx) @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock) async def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants): @@ -535,7 +534,7 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase): ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) - asyncio.run(self.cog.user_info.callback(self.cog, ctx)) + await self.cog.user_info(self.cog, ctx) create_embed.assert_called_once_with(ctx, self.author) ctx.send.assert_called_once() @@ -548,7 +547,7 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase): ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) - asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.author)) + await self.cog.user_info(self.cog, ctx, self.author) create_embed.assert_called_once_with(ctx, self.author) ctx.send.assert_called_once() @@ -561,7 +560,7 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase): ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=200)) - asyncio.run(self.cog.user_info.callback(self.cog, ctx)) + await self.cog.user_info(self.cog, ctx) create_embed.assert_called_once_with(ctx, self.moderator) ctx.send.assert_called_once() @@ -574,7 +573,7 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase): ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=50)) - asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target)) + await self.cog.user_info(self.cog, ctx, self.target) create_embed.assert_called_once_with(ctx, self.target) ctx.send.assert_called_once() -- cgit v1.2.3 From 92f901d498a18148c0b59bb2489f0d9b7e902b6d Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 18:16:52 +0300 Subject: (Snekbox Tests, discord.py 1.3.x Migrations): Removed `.callback` from commands calling. --- tests/bot/cogs/test_snekbox.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 1443f7cdc..d84e5accf 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -175,7 +175,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): self.cog.send_eval = AsyncMock(return_value=response) self.cog.continue_eval = AsyncMock(return_value=None) - await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') + await self.cog.eval_command(self.cog, ctx=ctx, code='MyAwesomeCode') self.cog.prepare_input.assert_called_once_with('MyAwesomeCode') self.cog.send_eval.assert_called_once_with(ctx, 'MyAwesomeFormattedCode') self.cog.continue_eval.assert_called_once_with(ctx, response) @@ -189,7 +189,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): self.cog.continue_eval = AsyncMock() self.cog.continue_eval.side_effect = ('MyAwesomeCode-2', None) - await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') + await self.cog.eval_command(self.cog, ctx=ctx, code='MyAwesomeCode') self.cog.prepare_input.has_calls(call('MyAwesomeCode'), call('MyAwesomeCode-2')) self.cog.send_eval.assert_called_with(ctx, 'MyAwesomeFormattedCode') self.cog.continue_eval.assert_called_with(ctx, response) @@ -201,7 +201,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): ctx.author.mention = '@LemonLemonishBeard#0042' ctx.send = AsyncMock() self.cog.jobs = (42,) - await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') + await self.cog.eval_command(self.cog, ctx=ctx, code='MyAwesomeCode') ctx.send.assert_called_once_with( "@LemonLemonishBeard#0042 You've already got a job running - please wait for it to finish!" ) @@ -210,7 +210,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): """Test if the eval command call the help command if no code is provided.""" ctx = MockContext() ctx.invoke = AsyncMock() - await self.cog.eval_command.callback(self.cog, ctx=ctx, code='') + await self.cog.eval_command(self.cog, ctx=ctx, code='') ctx.invoke.assert_called_once_with(self.bot.get_command("help"), "eval") async def test_send_eval(self): -- cgit v1.2.3 From c5949686fc03ecb74787cfd23412c8600638139f Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 18:19:17 +0300 Subject: (Silence Tests, discord.py 1.3.x Migrations): Removed `.callback` from commands calling. --- tests/bot/cogs/moderation/test_silence.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 3fd149f04..52b7d47f1 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -122,14 +122,14 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): starting_unsilenced_state=_silence_patch_return ): with mock.patch.object(self.cog, "_silence", return_value=_silence_patch_return): - await self.cog.silence.callback(self.cog, self.ctx, duration) + await self.cog.silence(self.cog, self.ctx, duration) self.ctx.send.assert_called_once_with(result_message) self.ctx.reset_mock() async def test_unsilence_sent_correct_discord_message(self): """Proper reply after a successful unsilence.""" with mock.patch.object(self.cog, "_unsilence", return_value=True): - await self.cog.unsilence.callback(self.cog, self.ctx) + await self.cog.unsilence(self.cog, self.ctx) self.ctx.send.assert_called_once_with(f"{Emojis.check_mark} unsilenced current channel.") async def test_silence_private_for_false(self): -- cgit v1.2.3 From ca4e21b1c7e8b537ac37f55b71eb29e09f16bf74 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 18:20:12 +0300 Subject: (Sync Cog Tests, discord.py 1.3.x Migrations): Removed `.callback` from commands calling. --- tests/bot/cogs/sync/test_cog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 81398c61f..a4745f7b4 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -344,14 +344,14 @@ class SyncCogCommandTests(SyncCogTestCase, CommandTestCase): async def test_sync_roles_command(self): """sync() should be called on the RoleSyncer.""" ctx = helpers.MockContext() - await self.cog.sync_roles_command.callback(self.cog, ctx) + await self.cog.sync_roles_command(self.cog, ctx) self.cog.role_syncer.sync.assert_called_once_with(ctx.guild, ctx) async def test_sync_users_command(self): """sync() should be called on the UserSyncer.""" ctx = helpers.MockContext() - await self.cog.sync_users_command.callback(self.cog, ctx) + await self.cog.sync_users_command(self.cog, ctx) self.cog.user_syncer.sync.assert_called_once_with(ctx.guild, ctx) -- cgit v1.2.3 From 56edfa39c0f03ae11647454fcb06415dc8cfcb20 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 18:57:55 +0300 Subject: (Docs, discord.py 1.3.x Migrations): Replaced `ctx.invoke` with direct calling command. --- bot/cogs/doc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 204cffb37..ddff9d14c 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -345,7 +345,7 @@ class Doc(commands.Cog): @commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True) async def docs_group(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None: """Lookup documentation for Python symbols.""" - await ctx.invoke(self.get_command, symbol) + await self.get_command(ctx, symbol) @docs_group.command(name='get', aliases=('g',)) async def get_command(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None: -- cgit v1.2.3 From c30e5b16d48aea5bef792de9186e73d5df4a94e4 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 19:02:36 +0300 Subject: (Eval, discord.py 1.3.x Migrations): Replaced `help` command getting with `ctx.send_help`. --- bot/cogs/eval.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 52136fc8d..2d52197e8 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -178,7 +178,7 @@ async def func(): # (None,) -> Any async def internal_group(self, ctx: Context) -> None: """Internal commands. Top secret!""" if not ctx.invoked_subcommand: - await ctx.invoke(self.bot.get_command("help"), "internal") + await ctx.send_help("internal") @internal_group.command(name='eval', aliases=('e',)) @with_role(Roles.admins, Roles.owners) -- cgit v1.2.3 From f4a95d904a5476639dfdcbbb5a08fed0b9b19813 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 19:06:59 +0300 Subject: (Extensions, discord.py 1.3.x Migrations): Replaced `help` command getting with `ctx.send_help`. --- bot/cogs/extensions.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index fb6cd9aa3..4493046e1 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -65,7 +65,7 @@ class Extensions(commands.Cog): @group(name="extensions", aliases=("ext", "exts", "c", "cogs"), invoke_without_command=True) async def extensions_group(self, ctx: Context) -> None: """Load, unload, reload, and list loaded extensions.""" - await ctx.invoke(self.bot.get_command("help"), "extensions") + await ctx.send_help("extensions") @extensions_group.command(name="load", aliases=("l",)) async def load_command(self, ctx: Context, *extensions: Extension) -> None: @@ -75,7 +75,7 @@ class Extensions(commands.Cog): If '\*' or '\*\*' is given as the name, all unloaded extensions will be loaded. """ # noqa: W605 if not extensions: - await ctx.invoke(self.bot.get_command("help"), "extensions load") + await ctx.send_help("extensions load") return if "*" in extensions or "**" in extensions: @@ -92,7 +92,7 @@ class Extensions(commands.Cog): If '\*' or '\*\*' is given as the name, all loaded extensions will be unloaded. """ # noqa: W605 if not extensions: - await ctx.invoke(self.bot.get_command("help"), "extensions unload") + await ctx.send_help("extensions unload") return blacklisted = "\n".join(UNLOAD_BLACKLIST & set(extensions)) @@ -118,7 +118,7 @@ class Extensions(commands.Cog): If '\*\*' is given as the name, all extensions, including unloaded ones, will be reloaded. """ # noqa: W605 if not extensions: - await ctx.invoke(self.bot.get_command("help"), "extensions reload") + await ctx.send_help("extensions reload") return if "**" in extensions: -- cgit v1.2.3 From 7b9e6b0b90ab3445b9fa7cde30f5a923486c4094 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 19:14:21 +0300 Subject: (Off-Topic Names, discord.py 1.3.x Migrations): Replaced `help` command getting with `ctx.send_help`. --- bot/cogs/off_topic_names.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 29aadedc4..fd386858e 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -96,7 +96,7 @@ class OffTopicNames(Cog): @with_role(*MODERATION_ROLES) async def otname_group(self, ctx: Context) -> None: """Add or list items from the off-topic channel name rotation.""" - await ctx.invoke(self.bot.get_command("help"), "otname") + await ctx.send_help("otname") @otname_group.command(name='add', aliases=('a',)) @with_role(*MODERATION_ROLES) -- cgit v1.2.3 From 1a2edf7b4cda8a39c86337e5a0effc5e8874b73c Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 19:17:13 +0300 Subject: (Reddit, discord.py 1.3.x Migrations): Replaced `help` command getting with `ctx.send_help`. --- bot/cogs/reddit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 7f0ba98d2..426c34bfa 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -246,7 +246,7 @@ class Reddit(Cog): @group(name="reddit", invoke_without_command=True) async def reddit_group(self, ctx: Context) -> None: """View the top posts from various subreddits.""" - await ctx.invoke(self.bot.get_command("help"), "reddit") + await ctx.send_help("reddit") @reddit_group.command(name="top") async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: -- cgit v1.2.3 From 8c33b8adaae3b40cb49c3da6fab72e1dadb3a6bc Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 19:21:00 +0300 Subject: (Reminders, discord.py 1.3.x Migrations): Replaced `ctx.invoke` with direct command calling, replaced `help` command getting with `ctx.send_help`. --- bot/cogs/reminders.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 24c279357..d5f59dd62 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -161,7 +161,7 @@ class Reminders(Scheduler, Cog): @group(name="remind", aliases=("reminder", "reminders"), invoke_without_command=True) async def remind_group(self, ctx: Context, expiration: Duration, *, content: str) -> None: """Commands for managing your reminders.""" - await ctx.invoke(self.new_reminder, expiration=expiration, content=content) + await self.new_reminder(ctx, expiration=expiration, content=content) @remind_group.command(name="new", aliases=("add", "create")) async def new_reminder(self, ctx: Context, expiration: Duration, *, content: str) -> t.Optional[discord.Message]: @@ -281,7 +281,7 @@ class Reminders(Scheduler, Cog): @remind_group.group(name="edit", aliases=("change", "modify"), invoke_without_command=True) async def edit_reminder_group(self, ctx: Context) -> None: """Commands for modifying your current reminders.""" - await ctx.invoke(self.bot.get_command("help"), "reminders", "edit") + await ctx.send_help("reminders edit") @edit_reminder_group.command(name="duration", aliases=("time",)) async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: Duration) -> None: -- cgit v1.2.3 From a11c34d533e9483b96564f4db9488ca6c8bc8db7 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 19:22:05 +0300 Subject: (Site, discord.py 1.3.x Migrations): Replaced `help` command getting with `ctx.send_help`. --- bot/cogs/site.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/site.py b/bot/cogs/site.py index 853e29568..c17761a2b 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -21,7 +21,7 @@ class Site(Cog): @group(name="site", aliases=("s",), invoke_without_command=True) async def site_group(self, ctx: Context) -> None: """Commands for getting info about our website.""" - await ctx.invoke(self.bot.get_command("help"), "site") + await ctx.send_help("site") @site_group.command(name="home", aliases=("about",)) async def site_main(self, ctx: Context) -> None: -- cgit v1.2.3 From 2fe4149e7728db3c4fc7989caa098e0f9d76e093 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 19:24:36 +0300 Subject: (Snekbox, discord.py 1.3.x Migrations): Replaced `help` command getting with `ctx.send_help`. --- bot/cogs/snekbox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 4ec08886c..99c1a7278 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -285,7 +285,7 @@ class Snekbox(Cog): return if not code: # None or empty string - await ctx.invoke(self.bot.get_command("help"), "eval") + await ctx.send_help("eval") return log.info(f"Received code from {ctx.author} for evaluation:\n{code}") -- cgit v1.2.3 From 538ef551be279ec1bed3465fcee711e3154fe234 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 19:27:54 +0300 Subject: (Utils, discord.py 1.3.x Migrations): Replaced `help` command getting with `ctx.send_help`. --- bot/cogs/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 3ed471bbf..0d34d4c71 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -58,7 +58,7 @@ class Utils(Cog): if pep_number.isdigit(): pep_number = int(pep_number) else: - await ctx.invoke(self.bot.get_command("help"), "pep") + await ctx.send_help("pep") return # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs. -- cgit v1.2.3 From 54606cd8f20197c39cff264972aaa4a34e47ca53 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 19:34:20 +0300 Subject: (Mod Management, discord.py 1.3.x Migrations): Replaced `help` command getting with `ctx.send_help`, replaced `ctx.invoke` with direct command call. --- bot/cogs/moderation/management.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 250a24247..075d45e2d 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -43,7 +43,7 @@ class ModManagement(commands.Cog): @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True) async def infraction_group(self, ctx: Context) -> None: """Infraction manipulation commands.""" - await ctx.invoke(self.bot.get_command("help"), "infraction") + await ctx.send_help("infraction") @infraction_group.command(name='edit') async def infraction_edit( @@ -183,9 +183,9 @@ class ModManagement(commands.Cog): async def infraction_search_group(self, ctx: Context, query: InfractionSearchQuery) -> None: """Searches for infractions in the database.""" if isinstance(query, discord.User): - await ctx.invoke(self.search_user, query) + await self.search_user(ctx, query) else: - await ctx.invoke(self.search_reason, query) + await self.search_reason(ctx, query) @infraction_search_group.command(name="user", aliases=("member", "id")) async def search_user(self, ctx: Context, user: t.Union[discord.User, proxy_user]) -> None: -- cgit v1.2.3 From 361fabc024880c071b1db7137186a42989dab2ad Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 19:40:41 +0300 Subject: (Mod Management, discord.py 1.3.x Migrations): Replaced `help` command getting with `ctx.send_help`. --- bot/cogs/watchchannels/bigbrother.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 903c87f85..37f2d2b9d 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -30,7 +30,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): @with_role(*MODERATION_ROLES) async def bigbrother_group(self, ctx: Context) -> None: """Monitors users by relaying their messages to the Big Brother watch channel.""" - await ctx.invoke(self.bot.get_command("help"), "bigbrother") + await ctx.send_help("bigbrother") @bigbrother_group.command(name='watched', aliases=('all', 'list')) @with_role(*MODERATION_ROLES) -- cgit v1.2.3 From b00f023466c044b5b459701a479bdfcb01d9bfa6 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 19:43:16 +0300 Subject: (Talent Pool, discord.py 1.3.x Migrations): Replaced `help` command getting with `ctx.send_help`. --- bot/cogs/watchchannels/talentpool.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index ad0c51fa6..b8473963d 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -34,7 +34,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @with_role(*MODERATION_ROLES) async def nomination_group(self, ctx: Context) -> None: """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" - await ctx.invoke(self.bot.get_command("help"), "talentpool") + await ctx.send_help("talentpool") @nomination_group.command(name='watched', aliases=('all', 'list')) @with_role(*MODERATION_ROLES) @@ -173,7 +173,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @with_role(*MODERATION_ROLES) async def nomination_edit_group(self, ctx: Context) -> None: """Commands to edit nominations.""" - await ctx.invoke(self.bot.get_command("help"), "talentpool", "edit") + await ctx.send_help("talentpool edit") @nomination_edit_group.command(name='reason') @with_role(*MODERATION_ROLES) -- cgit v1.2.3 From 41f3dfa1a93e0850c6120e5979f9a8a52386c516 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 19:49:36 +0300 Subject: (Snekbox Tests, discord.py 1.3.x Migrations): Fixed wrong assertion of help command call. --- tests/bot/cogs/test_snekbox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index d84e5accf..bcb3550f8 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -211,7 +211,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): ctx = MockContext() ctx.invoke = AsyncMock() await self.cog.eval_command(self.cog, ctx=ctx, code='') - ctx.invoke.assert_called_once_with(self.bot.get_command("help"), "eval") + ctx.send_help.assert_called_once_with("eval") async def test_send_eval(self): """Test the send_eval function.""" -- cgit v1.2.3 From f0795ea53247501cc38615f57aabe21685de7251 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 5 May 2020 02:19:03 +0200 Subject: Create utility function for uploading to paste service. --- bot/utils/__init__.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 9b32e515d..0f39a1bc8 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1,9 +1,42 @@ +import logging from abc import ABCMeta +from typing import Optional +from aiohttp import ClientConnectorError, ClientSession from discord.ext.commands import CogMeta +from bot.constants import URLs + +log = logging.getLogger(__name__) + class CogABCMeta(CogMeta, ABCMeta): """Metaclass for ABCs meant to be implemented as Cogs.""" pass + + +async def send_to_paste_service(http_session: ClientSession, contents: str, *, extension: str = "") -> Optional[str]: + """ + Upload `contents` to the paste service. + + `http_session` should be the current running ClientSession from aiohttp + `extension` is added to the output URL + + When an error occurs, `None` is returned, otherwise the generated URL with the suffix. + """ + extension = extension and f".{extension}" + log.debug(f"Sending contents of size {len(contents.encode())} bytes to paste service.") + paste_url = URLs.paste_service.format(key="documents") + try: + async with http_session.post(paste_url, data=contents) as response: + response_json = await response.json() + except ClientConnectorError: + log.warning(f"Failed to connect to paste service at url {paste_url}.") + return + if "message" in response_json: + log.warning(f"Paste service returned error {response_json['message']} with status code {response.status}.") + return + elif "key" in response_json: + log.trace(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") + return URLs.paste_service.format(key=response_json['key']) + extension -- cgit v1.2.3 From 4980726e3a68bb2bca966c9c3e09568da2162af0 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 5 May 2020 02:20:01 +0200 Subject: Attempt requests multiple times with connection errors. --- bot/utils/__init__.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 0f39a1bc8..6b9c890c8 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -9,6 +9,8 @@ from bot.constants import URLs log = logging.getLogger(__name__) +FAILED_REQUEST_ATTEMPTS = 3 + class CogABCMeta(CogMeta, ABCMeta): """Metaclass for ABCs meant to be implemented as Cogs.""" @@ -28,15 +30,18 @@ async def send_to_paste_service(http_session: ClientSession, contents: str, *, e extension = extension and f".{extension}" log.debug(f"Sending contents of size {len(contents.encode())} bytes to paste service.") paste_url = URLs.paste_service.format(key="documents") - try: - async with http_session.post(paste_url, data=contents) as response: - response_json = await response.json() - except ClientConnectorError: - log.warning(f"Failed to connect to paste service at url {paste_url}.") - return - if "message" in response_json: - log.warning(f"Paste service returned error {response_json['message']} with status code {response.status}.") - return - elif "key" in response_json: - log.trace(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") - return URLs.paste_service.format(key=response_json['key']) + extension + for attempt in range(1, FAILED_REQUEST_ATTEMPTS + 1): + try: + async with http_session.post(paste_url, data=contents) as response: + response_json = await response.json() + except ClientConnectorError: + log.warning( + f"Failed to connect to paste service at url {paste_url}, " + f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." + ) + if "message" in response_json: + log.warning(f"Paste service returned error {response_json['message']} with status code {response.status}.") + return + elif "key" in response_json: + log.trace(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") + return URLs.paste_service.format(key=response_json['key']) + extension -- cgit v1.2.3 From 2644316b07fdecbe834083c761ab5c7731e60a09 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 6 May 2020 02:20:57 +0200 Subject: Send long eval output to paste service. --- bot/cogs/eval.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 52136fc8d..b739668b0 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -15,6 +15,7 @@ from bot.bot import Bot from bot.constants import Roles from bot.decorators import with_role from bot.interpreter import Interpreter +from bot.utils import send_to_paste_service log = logging.getLogger(__name__) @@ -171,6 +172,15 @@ async def func(): # (None,) -> Any res = traceback.format_exc() out, embed = self._format(code, res) + if len(out) > 1500 or out.count("\n") > 15: + paste_link = await send_to_paste_service(self.bot.http_session, out, extension="py") + await ctx.send( + f"```py\n{out[:1500]}\n```" + f"... response truncated; full contents at {paste_link}", + embed=embed + ) + return + await ctx.send(f"```py\n{out}```", embed=embed) @group(name='internal', aliases=('int',)) -- cgit v1.2.3 From 1b98e6c839a3e841115fb6c150855e673bc1ef5b Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 6 May 2020 02:43:08 +0200 Subject: Increase log level. --- bot/utils/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 6b9c890c8..011e41227 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -43,5 +43,5 @@ async def send_to_paste_service(http_session: ClientSession, contents: str, *, e log.warning(f"Paste service returned error {response_json['message']} with status code {response.status}.") return elif "key" in response_json: - log.trace(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") + log.info(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") return URLs.paste_service.format(key=response_json['key']) + extension -- cgit v1.2.3 From d0d205409ccf00b14f535573b343831f31bd917c Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 6 May 2020 02:43:42 +0200 Subject: Handle failed paste uploads. --- bot/cogs/eval.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index b739668b0..c75c1e55f 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -174,9 +174,14 @@ async def func(): # (None,) -> Any out, embed = self._format(code, res) if len(out) > 1500 or out.count("\n") > 15: paste_link = await send_to_paste_service(self.bot.http_session, out, extension="py") + if paste_link is not None: + paste_text = f"full contents at {paste_link}" + else: + paste_text = "failed to upload contents to paste service." + await ctx.send( f"```py\n{out[:1500]}\n```" - f"... response truncated; full contents at {paste_link}", + f"... response truncated; {paste_text}", embed=embed ) return -- cgit v1.2.3 From 077a1ef1eb4eb07325dde5b6b625a84ccb5669ee Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 6 May 2020 02:47:15 +0200 Subject: Use new util function for uploading output. --- bot/cogs/snekbox.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 8d4688114..2aab8fdb1 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -14,6 +14,7 @@ from discord.ext.commands import Cog, Context, command, guild_only from bot.bot import Bot from bot.constants import Categories, Channels, Roles, URLs from bot.decorators import in_whitelist +from bot.utils import send_to_paste_service from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) @@ -70,17 +71,7 @@ class Snekbox(Cog): if len(output) > MAX_PASTE_LEN: log.info("Full output is too long to upload") return "too long to upload" - - url = URLs.paste_service.format(key="documents") - try: - async with self.bot.http_session.post(url, data=output, raise_for_status=True) as resp: - data = await resp.json() - - if "key" in data: - return URLs.paste_service.format(key=data["key"]) - except Exception: - # 400 (Bad Request) means there are too many characters - log.exception("Failed to upload full output to paste service!") + return await send_to_paste_service(self.bot.http_session, output, extension="txt") @staticmethod def prepare_input(code: str) -> str: -- cgit v1.2.3 From c94c0eaef4ccb64ee3f664ed65837b1f5afd5c59 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 7 May 2020 01:43:25 +0200 Subject: Continue on failed connections. Not using skipping the iteration but continuing directly caused `response_json` being checked but not defined in case of connection errors. Co-authored-by: MarkKoz --- bot/utils/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 011e41227..41e54c3d5 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -39,6 +39,7 @@ async def send_to_paste_service(http_session: ClientSession, contents: str, *, e f"Failed to connect to paste service at url {paste_url}, " f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." ) + continue if "message" in response_json: log.warning(f"Paste service returned error {response_json['message']} with status code {response.status}.") return -- cgit v1.2.3 From 93a805d950f9daf14ba50131547d888e1f6314b3 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 7 May 2020 01:45:28 +0200 Subject: Handle broad exceptions. In the case an unexpected exception happens, this allows us to try the request again or let the function exit gracefully in the case of multiple fails. --- bot/utils/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 41e54c3d5..b9290e5a6 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -40,6 +40,13 @@ async def send_to_paste_service(http_session: ClientSession, contents: str, *, e f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." ) continue + except Exception: + log.exception( + f"An unexpected error has occurred during handling of the request, " + f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." + ) + continue + if "message" in response_json: log.warning(f"Paste service returned error {response_json['message']} with status code {response.status}.") return -- cgit v1.2.3 From 8f7551540cc2770b498bfe38a9f72c0950bbd929 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 7 May 2020 01:51:27 +0200 Subject: continue on internal server errors. In the case we receive `"message"` in the json response, the server had an internal error and we can attempt the request again. --- bot/utils/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index b9290e5a6..b273b2cde 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -48,8 +48,11 @@ async def send_to_paste_service(http_session: ClientSession, contents: str, *, e continue if "message" in response_json: - log.warning(f"Paste service returned error {response_json['message']} with status code {response.status}.") - return + log.warning( + f"Paste service returned error {response_json['message']} with status code {response.status}, " + f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." + ) + continue elif "key" in response_json: log.info(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") return URLs.paste_service.format(key=response_json['key']) + extension -- cgit v1.2.3 From d98a418f9cafc8ce907293cb833cabfd68c92fb3 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 7 May 2020 01:52:44 +0200 Subject: Log unexpected JSON responses. --- bot/utils/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index b273b2cde..ec7cbd214 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -56,3 +56,7 @@ async def send_to_paste_service(http_session: ClientSession, contents: str, *, e elif "key" in response_json: log.info(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") return URLs.paste_service.format(key=response_json['key']) + extension + log.warning( + f"Got unexpected JSON response from paste service: {response_json}\n" + f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." + ) -- cgit v1.2.3 From 5b11b248b945cd2a732c6d8d430d117fc062cc8d Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 7 May 2020 16:46:32 +0200 Subject: Remove tests from moved function. --- tests/bot/cogs/test_snekbox.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 1dec0ccaf..d32d80ead 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -1,5 +1,4 @@ import asyncio -import logging import unittest from unittest.mock import AsyncMock, MagicMock, Mock, call, create_autospec, patch @@ -53,20 +52,6 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): raise_for_status=True ) - async def test_upload_output_gracefully_fallback_if_exception_during_request(self): - """Output upload gracefully fallback if the upload fail.""" - resp = MagicMock() - resp.json = AsyncMock(side_effect=Exception) - self.bot.http_session.post().__aenter__.return_value = resp - - log = logging.getLogger("bot.cogs.snekbox") - with self.assertLogs(logger=log, level='ERROR'): - await self.cog.upload_output('My awesome output!') - - async def test_upload_output_gracefully_fallback_if_no_key_in_response(self): - """Output upload gracefully fallback if there is no key entry in the response body.""" - self.assertEqual((await self.cog.upload_output('My awesome output!')), None) - def test_prepare_input(self): cases = ( ('print("Hello world!")', 'print("Hello world!")', 'non-formatted'), -- cgit v1.2.3 From 14c670dfa87e142e24c027e2976fa02b07c4d7ac Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 7 May 2020 17:11:56 +0200 Subject: Adjust behaviour for new func usage. --- tests/bot/cogs/test_snekbox.py | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index d32d80ead..f4c13fc43 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -35,21 +35,12 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): result = await self.cog.upload_output("-" * (snekbox.MAX_PASTE_LEN + 1)) self.assertEqual(result, "too long to upload") - async def test_upload_output(self): + @patch("bot.cogs.snekbox.send_to_paste_service") + async def test_upload_output(self, mock_paste_util): """Upload the eval output to the URLs.paste_service.format(key="documents") endpoint.""" - key = "MarkDiamond" - resp = MagicMock() - resp.json = AsyncMock(return_value={"key": key}) - self.bot.http_session.post().__aenter__.return_value = resp - - self.assertEqual( - await self.cog.upload_output("My awesome output"), - constants.URLs.paste_service.format(key=key) - ) - self.bot.http_session.post.assert_called_with( - constants.URLs.paste_service.format(key="documents"), - data="My awesome output", - raise_for_status=True + await self.cog.upload_output("Test output.") + mock_paste_util.assert_called_once_with( + self.bot.http_session, "Test output.", extension="txt" ) def test_prepare_input(self): -- cgit v1.2.3 From 5d96e96a2e8982ec57c1a19d1a085ceccd35a6d7 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Fri, 8 May 2020 01:38:14 +0200 Subject: Add tests for `send_to_paste_service`. --- tests/bot/utils/test_init.py | 74 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tests/bot/utils/test_init.py diff --git a/tests/bot/utils/test_init.py b/tests/bot/utils/test_init.py new file mode 100644 index 000000000..f3a8f5939 --- /dev/null +++ b/tests/bot/utils/test_init.py @@ -0,0 +1,74 @@ +import logging +import unittest +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +from aiohttp import ClientConnectorError + +from bot.utils import FAILED_REQUEST_ATTEMPTS, send_to_paste_service + + +class PasteTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.http_session = MagicMock() + + @patch("bot.utils.URLs.paste_service", "https://paste_service.com/{key}") + async def test_url_and_sent_contents(self): + """Correct url was used and post was called with expected data.""" + response = MagicMock( + json=AsyncMock(return_value={"key": ""}) + ) + self.http_session.post().__aenter__.return_value = response + self.http_session.post.reset_mock() + await send_to_paste_service(self.http_session, "Content") + self.http_session.post.assert_called_once_with("https://paste_service.com/documents", data="Content") + + @patch("bot.utils.URLs.paste_service", "https://paste_service.com/{key}") + async def test_paste_returns_correct_url_on_success(self): + """Url with specified extension is returned on successful requests.""" + key = "paste_key" + test_cases = ( + (f"https://paste_service.com/{key}.txt", "txt"), + (f"https://paste_service.com/{key}.py", "py"), + (f"https://paste_service.com/{key}", ""), + ) + response = MagicMock( + json=AsyncMock(return_value={"key": key}) + ) + self.http_session.post().__aenter__.return_value = response + + for expected_output, extension in test_cases: + with self.subTest(msg=f"Send contents with extension {repr(extension)}"): + self.assertEqual( + await send_to_paste_service(self.http_session, "", extension=extension), + expected_output + ) + + async def test_request_repeated_on_json_errors(self): + """Json with error message and invalid json are handled as errors and requests repeated.""" + test_cases = ({"message": "error"}, {"unexpected_key": None}, {}) + self.http_session.post().__aenter__.return_value = response = MagicMock() + self.http_session.post.reset_mock() + + for error_json in test_cases: + with self.subTest(error_json=error_json): + response.json = AsyncMock(return_value=error_json) + result = await send_to_paste_service(self.http_session, "") + self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) + self.assertIsNone(result) + + self.http_session.post.reset_mock() + + async def test_request_repeated_on_connection_errors(self): + """Requests are repeated in the case of connection errors.""" + self.http_session.post = MagicMock(side_effect=ClientConnectorError(Mock(), Mock())) + result = await send_to_paste_service(self.http_session, "") + self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) + self.assertIsNone(result) + + async def test_general_error_handled_and_request_repeated(self): + """All `Exception`s are handled, logged and request repeated.""" + self.http_session.post = MagicMock(side_effect=Exception) + result = await send_to_paste_service(self.http_session, "") + self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) + self.assertLogs("bot.utils", logging.ERROR) + self.assertIsNone(result) -- cgit v1.2.3 From 72d2f662ff84c8bfca448870e8d7e60777301a68 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 14 May 2020 19:39:45 +0300 Subject: Mod Utils Tests: Replace `has_active_infraction` with `get_active_infraction` --- tests/bot/cogs/moderation/test_utils.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 4f81a2477..248adbcb8 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -19,21 +19,21 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): self.user = MockUser(id=1234) self.ctx = MockContext(bot=self.bot, author=self.member) - async def test_user_has_active_infraction(self): + async def test_user_get_active_infraction(self): """ - Should request the API for active infractions and return `True` if the user has one or `False` otherwise. + Should request the API for active infractions and return infraction if the user has one or `None` otherwise. A message should be sent to the context indicating a user already has an infraction, if that's the case. """ test_cases = [ { "get_return_value": [], - "expected_output": False, + "expected_output": None, "infraction_nr": None }, { "get_return_value": [{"id": 123987}], - "expected_output": True, + "expected_output": {"id": 123987}, "infraction_nr": "123987" } ] @@ -51,7 +51,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): self.bot.api_client.get.return_value = case["get_return_value"] - result = await utils.has_active_infraction(self.ctx, self.member, "ban") + result = await utils.get_active_infraction(self.ctx, self.member, "ban") self.assertEqual(result, case["expected_output"]) self.bot.api_client.get.assert_awaited_once_with("bot/infractions", params=params) -- cgit v1.2.3 From 716699dedf6ec7afc76eb61d5402184d5a808dc7 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 19 May 2020 20:14:06 +0300 Subject: Source: Created initial cog layout + setup function --- bot/cogs/source.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 bot/cogs/source.py diff --git a/bot/cogs/source.py b/bot/cogs/source.py new file mode 100644 index 000000000..4897a16e3 --- /dev/null +++ b/bot/cogs/source.py @@ -0,0 +1,15 @@ +from discord.ext.commands import Cog + +from bot.bot import Bot + + +class Source(Cog): + """Cog of Python Discord project source information.""" + + def __init__(self, bot: Bot): + self.bot = bot + + +def setup(bot: Bot) -> None: + """Load `Source` cog.""" + bot.add_cog(Source(bot)) -- cgit v1.2.3 From c71895c99366f5096442420edde10c70e562a4dd Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 19 May 2020 20:21:58 +0300 Subject: Source: Create converter for source object converting --- bot/cogs/source.py | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 4897a16e3..76f75f83b 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -1,10 +1,39 @@ -from discord.ext.commands import Cog +from typing import Union + +from discord.ext.commands import BadArgument, Cog, Context, Converter, Command, HelpCommand from bot.bot import Bot +class SourceConverted(Converter): + """Convert argument to help command, command or Cog.""" + + async def convert(self, ctx: Context, argument: str) -> Union[HelpCommand, Command, Cog]: + """ + Convert argument into source object. + + Order how arguments is checked: + 1. When argument is `help`, return bot help command + 2. When argument is valid command, return this command + 3. When argument is valid Cog, return this Cog + 4. Otherwise raise `BadArgument` error + """ + if argument.lower() == "help": + return ctx.bot.help_command + + command = ctx.bot.get_command(argument) + if command: + return command + + cog = ctx.bot.get_cog(argument) + if cog: + return cog + + raise BadArgument(f"Unable to convert `{argument}` to help command, command or cog.") + + class Source(Cog): - """Cog of Python Discord project source information.""" + """Cog of Python Discord projects source information.""" def __init__(self, bot: Bot): self.bot = bot -- cgit v1.2.3 From a4ad94904638c94b44006d546861d399eba77ed5 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 20 May 2020 08:46:27 +0300 Subject: Source: Create `get_source_link` function that build item's GitHub link --- bot/cogs/source.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 76f75f83b..1f9e0e84d 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -1,11 +1,14 @@ +import inspect +import os from typing import Union -from discord.ext.commands import BadArgument, Cog, Context, Converter, Command, HelpCommand +from discord.ext.commands import BadArgument, Cog, Command, Context, Converter, HelpCommand from bot.bot import Bot +from bot.constants import URLs -class SourceConverted(Converter): +class SourceConverter(Converter): """Convert argument to help command, command or Cog.""" async def convert(self, ctx: Context, argument: str) -> Union[HelpCommand, Command, Cog]: @@ -38,6 +41,24 @@ class Source(Cog): def __init__(self, bot: Bot): self.bot = bot + @staticmethod + def get_source_link(source_item: Union[HelpCommand, Command, Cog]) -> str: + """Build GitHub link of source item.""" + if isinstance(source_item, HelpCommand): + src = type(source_item) + filename = inspect.getsourcefile(src) + elif isinstance(source_item, Command): + src = source_item.callback.__code__ + filename = src.co_filename + else: + src = type(source_item) + filename = inspect.getsourcefile(src) + + lines, first_line_no = inspect.getsourcelines(src) + file_location = os.path.relpath(filename) + + return f"{URLs.github_bot_repo}/blob/master/{file_location}#L{first_line_no}-L{first_line_no+len(lines)-1}" + def setup(bot: Bot) -> None: """Load `Source` cog.""" -- cgit v1.2.3 From 6e923cb6386e95b7ad56c9fc8d2374a0feffd49e Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 20 May 2020 08:46:48 +0300 Subject: Source: Add cog loading to __main__.py --- bot/__main__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/__main__.py b/bot/__main__.py index aa1d1aee8..d82adc802 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -57,6 +57,7 @@ bot.load_extension("bot.cogs.reddit") bot.load_extension("bot.cogs.reminders") bot.load_extension("bot.cogs.site") bot.load_extension("bot.cogs.snekbox") +bot.load_extension("bot.cogs.source") bot.load_extension("bot.cogs.stats") bot.load_extension("bot.cogs.sync") bot.load_extension("bot.cogs.tags") -- cgit v1.2.3 From dfda0abf922d49688774aae8e6f9c0ba8e44b96d Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 20 May 2020 09:10:05 +0300 Subject: Source: Create `build_embed` function that build embed of source item --- bot/cogs/source.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 1f9e0e84d..bce51aa80 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -2,11 +2,18 @@ import inspect import os from typing import Union +from discord import Embed from discord.ext.commands import BadArgument, Cog, Command, Context, Converter, HelpCommand from bot.bot import Bot from bot.constants import URLs +CANT_RUN_MESSAGE = "You can't run this command here." +CAN_RUN_MESSAGE = "You are able to run this command." + +COG_CHECK_FAIL = "You can't use commands what is in this Cog here." +COG_CHECK_PASS = "You can use commands from this Cog." + class SourceConverter(Converter): """Convert argument to help command, command or Cog.""" @@ -59,6 +66,26 @@ class Source(Cog): return f"{URLs.github_bot_repo}/blob/master/{file_location}#L{first_line_no}-L{first_line_no+len(lines)-1}" + @staticmethod + async def build_embed(link: str, source_object: Union[HelpCommand, Command, Cog], ctx: Context) -> Embed: + """Build embed based on source object.""" + if isinstance(source_object, HelpCommand): + title = "Help" + description = source_object.__doc__ + else: + title = source_object.qualified_name + description = source_object.help + + embed = Embed(title=title, description=description, url=link) + embed.add_field(name="Source Code", value=f"[Go to GitHub]({link})") + + if isinstance(source_object, Command): + embed.set_footer(text=CAN_RUN_MESSAGE if await source_object.can_run(ctx) else CANT_RUN_MESSAGE) + elif isinstance(source_object, Cog): + embed.set_footer(text=COG_CHECK_PASS if source_object.cog_check(ctx) else COG_CHECK_FAIL) + + return embed + def setup(bot: Bot) -> None: """Load `Source` cog.""" -- cgit v1.2.3 From b3742b8050aff7edbaccfa9d1d95843f5bf201a0 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 20 May 2020 09:11:50 +0300 Subject: Source: Create `source` command with alias `src` --- bot/cogs/source.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index bce51aa80..af29caaaa 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -3,7 +3,7 @@ import os from typing import Union from discord import Embed -from discord.ext.commands import BadArgument, Cog, Command, Context, Converter, HelpCommand +from discord.ext.commands import BadArgument, Cog, Command, Context, Converter, HelpCommand, group from bot.bot import Bot from bot.constants import URLs @@ -48,6 +48,12 @@ class Source(Cog): def __init__(self, bot: Bot): self.bot = bot + @group(name='source', aliases=('src',), invoke_without_command=True) + async def source_command(self, ctx: Context, *, source_item: SourceConverter) -> None: + """Get GitHub link and information about help command, command or Cog.""" + url = self.get_source_link(source_item) + await ctx.send(embed=await self.build_embed(url, source_item, ctx)) + @staticmethod def get_source_link(source_item: Union[HelpCommand, Command, Cog]) -> str: """Build GitHub link of source item.""" -- cgit v1.2.3 From ce34adbc64bacd15eeb1a2bfee7b0d022ec969eb Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 20 May 2020 09:17:26 +0300 Subject: Source: Make `source` command to `command` instead `group` --- bot/cogs/source.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index af29caaaa..1774d0085 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -3,7 +3,7 @@ import os from typing import Union from discord import Embed -from discord.ext.commands import BadArgument, Cog, Command, Context, Converter, HelpCommand, group +from discord.ext.commands import BadArgument, Cog, Command, Context, Converter, HelpCommand, command from bot.bot import Bot from bot.constants import URLs @@ -48,7 +48,7 @@ class Source(Cog): def __init__(self, bot: Bot): self.bot = bot - @group(name='source', aliases=('src',), invoke_without_command=True) + @command(name="source", aliases=("src",)) async def source_command(self, ctx: Context, *, source_item: SourceConverter) -> None: """Get GitHub link and information about help command, command or Cog.""" url = self.get_source_link(source_item) -- cgit v1.2.3 From 1bb52f815e93c2e3d3fa565c150bbc5effff94f2 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 20 May 2020 09:18:04 +0300 Subject: Source: Remove `command` shadowing on converter --- bot/cogs/source.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 1774d0085..c628c6b29 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -31,9 +31,9 @@ class SourceConverter(Converter): if argument.lower() == "help": return ctx.bot.help_command - command = ctx.bot.get_command(argument) - if command: - return command + cmd = ctx.bot.get_command(argument) + if cmd: + return cmd cog = ctx.bot.get_cog(argument) if cog: -- cgit v1.2.3 From e5239c4b20d2496cf5a96192981f050c87150acd Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 20 May 2020 09:37:26 +0300 Subject: Source: Implement no argument GitHub repo response --- bot/cogs/source.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index c628c6b29..22b75e2ee 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -1,9 +1,9 @@ import inspect import os -from typing import Union +from typing import Optional, Union from discord import Embed -from discord.ext.commands import BadArgument, Cog, Command, Context, Converter, HelpCommand, command +from discord.ext.commands import Cog, Command, Context, Converter, HelpCommand, command from bot.bot import Bot from bot.constants import URLs @@ -18,7 +18,7 @@ COG_CHECK_PASS = "You can use commands from this Cog." class SourceConverter(Converter): """Convert argument to help command, command or Cog.""" - async def convert(self, ctx: Context, argument: str) -> Union[HelpCommand, Command, Cog]: + async def convert(self, ctx: Context, argument: str) -> Union[HelpCommand, Command, Cog, None]: """ Convert argument into source object. @@ -39,7 +39,7 @@ class SourceConverter(Converter): if cog: return cog - raise BadArgument(f"Unable to convert `{argument}` to help command, command or cog.") + return None class Source(Cog): @@ -49,8 +49,14 @@ class Source(Cog): self.bot = bot @command(name="source", aliases=("src",)) - async def source_command(self, ctx: Context, *, source_item: SourceConverter) -> None: + async def source_command(self, ctx: Context, *, source_item: Optional[SourceConverter] = None) -> None: """Get GitHub link and information about help command, command or Cog.""" + if not source_item: + embed = Embed(title="Bot GitHub Repository", url=URLs.github_bot_repo) + embed.add_field(name="Repository", value=f"[Go to GitHub]({URLs.github_bot_repo})") + await ctx.send(embed=embed) + return + url = self.get_source_link(source_item) await ctx.send(embed=await self.build_embed(url, source_item, ctx)) -- cgit v1.2.3 From 6d0a1b0c9e3f278f2b660659efd89db3c4a3595a Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 20 May 2020 09:40:26 +0300 Subject: Source: Fix `Cog` instance of source no `help` attribute --- bot/cogs/source.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 22b75e2ee..1820392f3 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -84,9 +84,12 @@ class Source(Cog): if isinstance(source_object, HelpCommand): title = "Help" description = source_object.__doc__ - else: + elif isinstance(source_object, Command): title = source_object.qualified_name description = source_object.help + else: + title = source_object.qualified_name + description = source_object.description embed = Embed(title=title, description=description, url=link) embed.add_field(name="Source Code", value=f"[Go to GitHub]({link})") -- cgit v1.2.3 From 00445f54a8593ff14d2f9c9595be86f15ab78072 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 20 May 2020 16:23:05 +0300 Subject: Source: Make converter raising `BadArgument` again --- bot/cogs/source.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 1820392f3..633cb8ccf 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -1,9 +1,9 @@ import inspect import os -from typing import Optional, Union +from typing import Union from discord import Embed -from discord.ext.commands import Cog, Command, Context, Converter, HelpCommand, command +from discord.ext.commands import BadArgument, Cog, Command, Context, Converter, HelpCommand, command from bot.bot import Bot from bot.constants import URLs @@ -18,7 +18,7 @@ COG_CHECK_PASS = "You can use commands from this Cog." class SourceConverter(Converter): """Convert argument to help command, command or Cog.""" - async def convert(self, ctx: Context, argument: str) -> Union[HelpCommand, Command, Cog, None]: + async def convert(self, ctx: Context, argument: str) -> Union[HelpCommand, Command, Cog]: """ Convert argument into source object. @@ -39,7 +39,7 @@ class SourceConverter(Converter): if cog: return cog - return None + raise BadArgument(f"Unable to convert `{argument}` to valid command or Cog.") class Source(Cog): @@ -49,7 +49,7 @@ class Source(Cog): self.bot = bot @command(name="source", aliases=("src",)) - async def source_command(self, ctx: Context, *, source_item: Optional[SourceConverter] = None) -> None: + async def source_command(self, ctx: Context, *, source_item: SourceConverter = None) -> None: """Get GitHub link and information about help command, command or Cog.""" if not source_item: embed = Embed(title="Bot GitHub Repository", url=URLs.github_bot_repo) -- cgit v1.2.3 From dd05246e29fa46ff53456a499244373c23a24d06 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 20 May 2020 16:23:58 +0300 Subject: Source: Remove links from title of embeds --- bot/cogs/source.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 633cb8ccf..b285b4089 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -52,7 +52,7 @@ class Source(Cog): async def source_command(self, ctx: Context, *, source_item: SourceConverter = None) -> None: """Get GitHub link and information about help command, command or Cog.""" if not source_item: - embed = Embed(title="Bot GitHub Repository", url=URLs.github_bot_repo) + embed = Embed(title="Bot GitHub Repository") embed.add_field(name="Repository", value=f"[Go to GitHub]({URLs.github_bot_repo})") await ctx.send(embed=embed) return @@ -91,7 +91,7 @@ class Source(Cog): title = source_object.qualified_name description = source_object.description - embed = Embed(title=title, description=description, url=link) + embed = Embed(title=title, description=description) embed.add_field(name="Source Code", value=f"[Go to GitHub]({link})") if isinstance(source_object, Command): -- cgit v1.2.3 From 36ef3514674812afab6c94b12b3f9d3768b324f5 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 20 May 2020 16:25:26 +0300 Subject: Source: Remove Cog check displaying from command --- bot/cogs/source.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index b285b4089..220da535d 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -11,9 +11,6 @@ from bot.constants import URLs CANT_RUN_MESSAGE = "You can't run this command here." CAN_RUN_MESSAGE = "You are able to run this command." -COG_CHECK_FAIL = "You can't use commands what is in this Cog here." -COG_CHECK_PASS = "You can use commands from this Cog." - class SourceConverter(Converter): """Convert argument to help command, command or Cog.""" @@ -96,8 +93,6 @@ class Source(Cog): if isinstance(source_object, Command): embed.set_footer(text=CAN_RUN_MESSAGE if await source_object.can_run(ctx) else CANT_RUN_MESSAGE) - elif isinstance(source_object, Cog): - embed.set_footer(text=COG_CHECK_PASS if source_object.cog_check(ctx) else COG_CHECK_FAIL) return embed -- cgit v1.2.3 From e2ce4ac6f372a92cc8c31c09237521e6b3aeb23b Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 20 May 2020 16:35:11 +0300 Subject: Source: Rename cog + move checks status from footer to field - Renamed cog from `Source` to `BotSource` for itself (bot will be unable to get cog, because this always return command). - Moved checks status from footer to field and changed it's content. --- bot/cogs/source.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 220da535d..972507762 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -8,9 +8,6 @@ from discord.ext.commands import BadArgument, Cog, Command, Context, Converter, from bot.bot import Bot from bot.constants import URLs -CANT_RUN_MESSAGE = "You can't run this command here." -CAN_RUN_MESSAGE = "You are able to run this command." - class SourceConverter(Converter): """Convert argument to help command, command or Cog.""" @@ -39,7 +36,7 @@ class SourceConverter(Converter): raise BadArgument(f"Unable to convert `{argument}` to valid command or Cog.") -class Source(Cog): +class BotSource(Cog): """Cog of Python Discord projects source information.""" def __init__(self, bot: Bot): @@ -92,11 +89,11 @@ class Source(Cog): embed.add_field(name="Source Code", value=f"[Go to GitHub]({link})") if isinstance(source_object, Command): - embed.set_footer(text=CAN_RUN_MESSAGE if await source_object.can_run(ctx) else CANT_RUN_MESSAGE) + embed.add_field(name="Can be used by you here?", value=await source_object.can_run(ctx)) return embed def setup(bot: Bot) -> None: """Load `Source` cog.""" - bot.add_cog(Source(bot)) + bot.add_cog(BotSource(bot)) -- cgit v1.2.3 From dc96a187cf7af6d4d1d39a325e3ffad67d3549a5 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 20 May 2020 16:38:56 +0300 Subject: Source: Fix description of cog --- bot/cogs/source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 972507762..5b8d8ded2 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -37,7 +37,7 @@ class SourceConverter(Converter): class BotSource(Cog): - """Cog of Python Discord projects source information.""" + """Cog of Python Discord Python bot project source information.""" def __init__(self, bot: Bot): self.bot = bot -- cgit v1.2.3 From 0b266169160a8368a3c7eba3fcdfb404b657232e Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sat, 30 May 2020 02:41:05 +0200 Subject: Truncate amount of lines in int eval output to 15. Previously the amount of newlines was checked and uploaded to the paste service if above 15 but the sent message was not truncated to only include that amount of lines. --- bot/cogs/eval.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index c75c1e55f..edb59d286 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -172,7 +172,15 @@ async def func(): # (None,) -> Any res = traceback.format_exc() out, embed = self._format(code, res) - if len(out) > 1500 or out.count("\n") > 15: + # Truncate output to max 15 lines or 1500 characters + newline_truncate_index = find_nth_occurrence(out, "\n", 15) + + if newline_truncate_index is None or newline_truncate_index > 1500: + truncate_index = 1500 + else: + truncate_index = newline_truncate_index + + if len(out) > truncate_index: paste_link = await send_to_paste_service(self.bot.http_session, out, extension="py") if paste_link is not None: paste_text = f"full contents at {paste_link}" @@ -180,7 +188,7 @@ async def func(): # (None,) -> Any paste_text = "failed to upload contents to paste service." await ctx.send( - f"```py\n{out[:1500]}\n```" + f"```py\n{out[:truncate_index]}\n```" f"... response truncated; {paste_text}", embed=embed ) @@ -212,6 +220,16 @@ async def func(): # (None,) -> Any await self._eval(ctx, code) +def find_nth_occurrence(string: str, substring: str, n: int) -> Optional[int]: + """Return index of `n`th occurrence of `substring` in `string`, or None if not found.""" + index = 0 + for _ in range(n): + index = string.find(substring, index+1) + if index == -1: + return None + return index + + def setup(bot: Bot) -> None: """Load the CodeEval cog.""" bot.add_cog(CodeEval(bot)) -- cgit v1.2.3 From c8f5f8597c8eb3cccf9cd7867fbc4777cc4b4f99 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sat, 30 May 2020 02:42:38 +0200 Subject: Strip empty lines from int eval output. The output generates trailing newlines, which can cause the output to be uploaded to the paste service in cases where it's not needed, as discord will automatically remove those in messages. --- bot/cogs/eval.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index edb59d286..5b7469bdf 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -172,6 +172,8 @@ async def func(): # (None,) -> Any res = traceback.format_exc() out, embed = self._format(code, res) + out = out.rstrip("\n") # Strip empty lines from output + # Truncate output to max 15 lines or 1500 characters newline_truncate_index = find_nth_occurrence(out, "\n", 15) -- cgit v1.2.3 From e5534bc73d4b2d7b4b87326ab3b729b955e7c344 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 08:26:23 +0300 Subject: Source: Fix docstrings Co-authored-by: Mark --- bot/cogs/source.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 5b8d8ded2..6e71ae5b2 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -10,7 +10,7 @@ from bot.constants import URLs class SourceConverter(Converter): - """Convert argument to help command, command or Cog.""" + """Convert an argument into a help command, command, or cog.""" async def convert(self, ctx: Context, argument: str) -> Union[HelpCommand, Command, Cog]: """ @@ -37,14 +37,14 @@ class SourceConverter(Converter): class BotSource(Cog): - """Cog of Python Discord Python bot project source information.""" + """Displays information about the bot's source code.""" def __init__(self, bot: Bot): self.bot = bot @command(name="source", aliases=("src",)) async def source_command(self, ctx: Context, *, source_item: SourceConverter = None) -> None: - """Get GitHub link and information about help command, command or Cog.""" + """Display information and a GitHub link to the source code of a command or cog.""" if not source_item: embed = Embed(title="Bot GitHub Repository") embed.add_field(name="Repository", value=f"[Go to GitHub]({URLs.github_bot_repo})") @@ -95,5 +95,5 @@ class BotSource(Cog): def setup(bot: Bot) -> None: - """Load `Source` cog.""" + """Load the BotSource cog.""" bot.add_cog(BotSource(bot)) -- cgit v1.2.3 From befbb1afed56b60336c8668a9134e28e069e0eac Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 08:47:46 +0300 Subject: Source: Remove checks running from source command --- bot/cogs/source.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 6e71ae5b2..7076c1eb3 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -52,7 +52,7 @@ class BotSource(Cog): return url = self.get_source_link(source_item) - await ctx.send(embed=await self.build_embed(url, source_item, ctx)) + await ctx.send(embed=await self.build_embed(url, source_item)) @staticmethod def get_source_link(source_item: Union[HelpCommand, Command, Cog]) -> str: @@ -73,7 +73,7 @@ class BotSource(Cog): return f"{URLs.github_bot_repo}/blob/master/{file_location}#L{first_line_no}-L{first_line_no+len(lines)-1}" @staticmethod - async def build_embed(link: str, source_object: Union[HelpCommand, Command, Cog], ctx: Context) -> Embed: + async def build_embed(link: str, source_object: Union[HelpCommand, Command, Cog]) -> Embed: """Build embed based on source object.""" if isinstance(source_object, HelpCommand): title = "Help" @@ -88,9 +88,6 @@ class BotSource(Cog): embed = Embed(title=title, description=description) embed.add_field(name="Source Code", value=f"[Go to GitHub]({link})") - if isinstance(source_object, Command): - embed.add_field(name="Can be used by you here?", value=await source_object.can_run(ctx)) - return embed -- cgit v1.2.3 From 756ed4286339a26e6e1e4edb7431a026d8817881 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 09:09:35 +0300 Subject: Source: Direct aliases to their original commands --- bot/cogs/source.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 7076c1eb3..0880dd62f 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -54,15 +54,20 @@ class BotSource(Cog): url = self.get_source_link(source_item) await ctx.send(embed=await self.build_embed(url, source_item)) - @staticmethod - def get_source_link(source_item: Union[HelpCommand, Command, Cog]) -> str: + def get_source_link(self, source_item: Union[HelpCommand, Command, Cog]) -> str: """Build GitHub link of source item.""" if isinstance(source_item, HelpCommand): src = type(source_item) filename = inspect.getsourcefile(src) elif isinstance(source_item, Command): - src = source_item.callback.__code__ - filename = src.co_filename + if source_item.cog_name == "Alias": + cmd_name = source_item.callback.__name__.replace("_alias", "") + cmd = self.bot.get_command(cmd_name.replace("_", " ")) + src = cmd.callback.__code__ + filename = src.co_filename + else: + src = source_item.callback.__code__ + filename = src.co_filename else: src = type(source_item) filename = inspect.getsourcefile(src) @@ -72,15 +77,20 @@ class BotSource(Cog): return f"{URLs.github_bot_repo}/blob/master/{file_location}#L{first_line_no}-L{first_line_no+len(lines)-1}" - @staticmethod - async def build_embed(link: str, source_object: Union[HelpCommand, Command, Cog]) -> Embed: + async def build_embed(self, link: str, source_object: Union[HelpCommand, Command, Cog]) -> Embed: """Build embed based on source object.""" if isinstance(source_object, HelpCommand): title = "Help" description = source_object.__doc__ elif isinstance(source_object, Command): + if source_object.cog_name == "Alias": + cmd_name = source_object.callback.__name__.replace("_alias", "") + cmd = self.bot.get_command(cmd_name.replace("_", " ")) + description = cmd.help + else: + description = source_object.help + title = source_object.qualified_name - description = source_object.help else: title = source_object.qualified_name description = source_object.description -- cgit v1.2.3 From a064e2b6b4f6fabdb6a0fae5de5ee957dbb85b75 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 09:31:07 +0300 Subject: Source: Implement tags file showing to source command --- bot/cogs/source.py | 43 +++++++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 0880dd62f..1c702f81d 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -10,21 +10,22 @@ from bot.constants import URLs class SourceConverter(Converter): - """Convert an argument into a help command, command, or cog.""" - - async def convert(self, ctx: Context, argument: str) -> Union[HelpCommand, Command, Cog]: - """ - Convert argument into source object. - - Order how arguments is checked: - 1. When argument is `help`, return bot help command - 2. When argument is valid command, return this command - 3. When argument is valid Cog, return this Cog - 4. Otherwise raise `BadArgument` error - """ + """Convert an argument into a help command, tag, command, or cog.""" + + async def convert(self, ctx: Context, argument: str) -> Union[HelpCommand, Command, Cog, str]: + """Convert argument into source object.""" if argument.lower() == "help": return ctx.bot.help_command + tags_cog = ctx.bot.get_cog("Tags") + + if argument.lower() in tags_cog._cache: + tag = argument.lower() + if tags_cog._cache[tag]["restricted_to"] != "developers": + return f"bot/resources/tags/{tags_cog._cache[tag]['restricted_to']}/{tag}.md" + else: + return f"bot/resources/tags/{tag}.md" + cmd = ctx.bot.get_command(argument) if cmd: return cmd @@ -44,7 +45,7 @@ class BotSource(Cog): @command(name="source", aliases=("src",)) async def source_command(self, ctx: Context, *, source_item: SourceConverter = None) -> None: - """Display information and a GitHub link to the source code of a command or cog.""" + """Display information and a GitHub link to the source code of a command, tag, or cog.""" if not source_item: embed = Embed(title="Bot GitHub Repository") embed.add_field(name="Repository", value=f"[Go to GitHub]({URLs.github_bot_repo})") @@ -54,7 +55,7 @@ class BotSource(Cog): url = self.get_source_link(source_item) await ctx.send(embed=await self.build_embed(url, source_item)) - def get_source_link(self, source_item: Union[HelpCommand, Command, Cog]) -> str: + def get_source_link(self, source_item: Union[HelpCommand, Command, Cog, str]) -> str: """Build GitHub link of source item.""" if isinstance(source_item, HelpCommand): src = type(source_item) @@ -68,14 +69,21 @@ class BotSource(Cog): else: src = source_item.callback.__code__ filename = src.co_filename + elif isinstance(source_item, str): + filename = source_item else: src = type(source_item) filename = inspect.getsourcefile(src) - lines, first_line_no = inspect.getsourcelines(src) + if not isinstance(source_item, str): + lines, first_line_no = inspect.getsourcelines(src) + lines_extension = f"#L{first_line_no}-L{first_line_no+len(lines)-1}" + else: + lines_extension = "" + file_location = os.path.relpath(filename) - return f"{URLs.github_bot_repo}/blob/master/{file_location}#L{first_line_no}-L{first_line_no+len(lines)-1}" + return f"{URLs.github_bot_repo}/blob/master/{file_location}{lines_extension}" async def build_embed(self, link: str, source_object: Union[HelpCommand, Command, Cog]) -> Embed: """Build embed based on source object.""" @@ -91,6 +99,9 @@ class BotSource(Cog): description = source_object.help title = source_object.qualified_name + elif isinstance(source_object, str): + title = f"Tag: {source_object.split('/')[-1].split('.')[0]}" + description = "" else: title = source_object.qualified_name description = source_object.description -- cgit v1.2.3 From bb6cc2193cad398d68db29d4f991fce94ae06549 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 09:44:34 +0300 Subject: Source: Migrate from os.path to Path --- bot/cogs/source.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 1c702f81d..21f18f45f 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -1,5 +1,5 @@ import inspect -import os +from pathlib import Path from typing import Union from discord import Embed @@ -22,9 +22,9 @@ class SourceConverter(Converter): if argument.lower() in tags_cog._cache: tag = argument.lower() if tags_cog._cache[tag]["restricted_to"] != "developers": - return f"bot/resources/tags/{tags_cog._cache[tag]['restricted_to']}/{tag}.md" + return f"/bot/bot/resources/tags/{tags_cog._cache[tag]['restricted_to']}/{tag}.md" else: - return f"bot/resources/tags/{tag}.md" + return f"/bot/bot/resources/tags/{tag}.md" cmd = ctx.bot.get_command(argument) if cmd: @@ -74,6 +74,7 @@ class BotSource(Cog): else: src = type(source_item) filename = inspect.getsourcefile(src) + print(filename) if not isinstance(source_item, str): lines, first_line_no = inspect.getsourcelines(src) @@ -81,7 +82,8 @@ class BotSource(Cog): else: lines_extension = "" - file_location = os.path.relpath(filename) + file_location = Path(filename).relative_to("/bot/") + print(file_location) return f"{URLs.github_bot_repo}/blob/master/{file_location}{lines_extension}" -- cgit v1.2.3 From 9db1377d8c4796d0e2eedc8eeee039566a7770b5 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 09:46:15 +0300 Subject: Source: Move big unions to variable of type --- bot/cogs/source.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 21f18f45f..40200eb69 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -8,11 +8,13 @@ from discord.ext.commands import BadArgument, Cog, Command, Context, Converter, from bot.bot import Bot from bot.constants import URLs +SourceType = Union[HelpCommand, Command, Cog, str] + class SourceConverter(Converter): """Convert an argument into a help command, tag, command, or cog.""" - async def convert(self, ctx: Context, argument: str) -> Union[HelpCommand, Command, Cog, str]: + async def convert(self, ctx: Context, argument: str) -> SourceType: """Convert argument into source object.""" if argument.lower() == "help": return ctx.bot.help_command @@ -55,7 +57,7 @@ class BotSource(Cog): url = self.get_source_link(source_item) await ctx.send(embed=await self.build_embed(url, source_item)) - def get_source_link(self, source_item: Union[HelpCommand, Command, Cog, str]) -> str: + def get_source_link(self, source_item: SourceType) -> str: """Build GitHub link of source item.""" if isinstance(source_item, HelpCommand): src = type(source_item) @@ -87,7 +89,7 @@ class BotSource(Cog): return f"{URLs.github_bot_repo}/blob/master/{file_location}{lines_extension}" - async def build_embed(self, link: str, source_object: Union[HelpCommand, Command, Cog]) -> Embed: + async def build_embed(self, link: str, source_object: SourceType) -> Embed: """Build embed based on source object.""" if isinstance(source_object, HelpCommand): title = "Help" -- cgit v1.2.3 From 2db5add0893d090b7bdbc172ea2e83044301b2f6 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 09:54:01 +0300 Subject: Source: Implement file and line showing in source embed footer --- bot/cogs/source.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 40200eb69..fbe519c43 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -1,6 +1,6 @@ import inspect from pathlib import Path -from typing import Union +from typing import Optional, Tuple, Union from discord import Embed from discord.ext.commands import BadArgument, Cog, Command, Context, Converter, HelpCommand, command @@ -54,10 +54,10 @@ class BotSource(Cog): await ctx.send(embed=embed) return - url = self.get_source_link(source_item) - await ctx.send(embed=await self.build_embed(url, source_item)) + url, location, first_line = self.get_source_link(source_item) + await ctx.send(embed=await self.build_embed(url, source_item, location, first_line)) - def get_source_link(self, source_item: SourceType) -> str: + def get_source_link(self, source_item: SourceType) -> Tuple[str, str, Optional[int]]: """Build GitHub link of source item.""" if isinstance(source_item, HelpCommand): src = type(source_item) @@ -82,14 +82,15 @@ class BotSource(Cog): lines, first_line_no = inspect.getsourcelines(src) lines_extension = f"#L{first_line_no}-L{first_line_no+len(lines)-1}" else: + first_line_no = None lines_extension = "" file_location = Path(filename).relative_to("/bot/") - print(file_location) + url = f"{URLs.github_bot_repo}/blob/master/{file_location}{lines_extension}" - return f"{URLs.github_bot_repo}/blob/master/{file_location}{lines_extension}" + return url, file_location, first_line_no or None - async def build_embed(self, link: str, source_object: SourceType) -> Embed: + async def build_embed(self, link: str, source_object: SourceType, loc: str, first_line: Optional[int]) -> Embed: """Build embed based on source object.""" if isinstance(source_object, HelpCommand): title = "Help" @@ -112,6 +113,8 @@ class BotSource(Cog): embed = Embed(title=title, description=description) embed.add_field(name="Source Code", value=f"[Go to GitHub]({link})") + line_text = f":{first_line}" if first_line else "" + embed.set_footer(text=f"{loc}{line_text}") return embed -- cgit v1.2.3 From ff308186375eee5ddf734d9d0d49879c49995b29 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 11:53:28 +0300 Subject: Source: Show only first line of every source item docstring instead full --- bot/cogs/source.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index fbe519c43..232ee618e 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -76,7 +76,6 @@ class BotSource(Cog): else: src = type(source_item) filename = inspect.getsourcefile(src) - print(filename) if not isinstance(source_item, str): lines, first_line_no = inspect.getsourcelines(src) @@ -94,14 +93,14 @@ class BotSource(Cog): """Build embed based on source object.""" if isinstance(source_object, HelpCommand): title = "Help" - description = source_object.__doc__ + description = source_object.__doc__.splitlines()[1] elif isinstance(source_object, Command): if source_object.cog_name == "Alias": cmd_name = source_object.callback.__name__.replace("_alias", "") cmd = self.bot.get_command(cmd_name.replace("_", " ")) - description = cmd.help + description = cmd.short_doc else: - description = source_object.help + description = source_object.short_doc title = source_object.qualified_name elif isinstance(source_object, str): @@ -109,7 +108,7 @@ class BotSource(Cog): description = "" else: title = source_object.qualified_name - description = source_object.description + description = source_object.description.splitlines()[0] embed = Embed(title=title, description=description) embed.add_field(name="Source Code", value=f"[Go to GitHub]({link})") -- cgit v1.2.3 From 522c7489ea86d5c150145d610fc3a0fef7bd16bc Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 11:55:14 +0300 Subject: Source: Add thumbnail to source command bot repo embed --- bot/cogs/source.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 232ee618e..3f03f790c 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -51,6 +51,7 @@ class BotSource(Cog): if not source_item: embed = Embed(title="Bot GitHub Repository") embed.add_field(name="Repository", value=f"[Go to GitHub]({URLs.github_bot_repo})") + embed.set_thumbnail(url="https://avatars1.githubusercontent.com/u/9919") await ctx.send(embed=embed) return -- cgit v1.2.3 From ed8298cad287c2bc37566d2688b77dc64d62a7af Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 11:57:29 +0300 Subject: Source: Few text fixes, made help command detection better --- bot/cogs/source.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 3f03f790c..32f8d5e08 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -16,7 +16,7 @@ class SourceConverter(Converter): async def convert(self, ctx: Context, argument: str) -> SourceType: """Convert argument into source object.""" - if argument.lower() == "help": + if argument.lower().startswith("help"): return ctx.bot.help_command tags_cog = ctx.bot.get_cog("Tags") @@ -49,7 +49,7 @@ class BotSource(Cog): async def source_command(self, ctx: Context, *, source_item: SourceConverter = None) -> None: """Display information and a GitHub link to the source code of a command, tag, or cog.""" if not source_item: - embed = Embed(title="Bot GitHub Repository") + embed = Embed(title="Bot's GitHub Repository") embed.add_field(name="Repository", value=f"[Go to GitHub]({URLs.github_bot_repo})") embed.set_thumbnail(url="https://avatars1.githubusercontent.com/u/9919") await ctx.send(embed=embed) @@ -93,7 +93,7 @@ class BotSource(Cog): async def build_embed(self, link: str, source_object: SourceType, loc: str, first_line: Optional[int]) -> Embed: """Build embed based on source object.""" if isinstance(source_object, HelpCommand): - title = "Help" + title = "Help Command" description = source_object.__doc__.splitlines()[1] elif isinstance(source_object, Command): if source_object.cog_name == "Alias": -- cgit v1.2.3 From 0a4b365f5efdb31450647b8d628b3787142d4617 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 11:58:54 +0300 Subject: Source: Add command and cog prefixes to title of embed --- bot/cogs/source.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 32f8d5e08..9e6109ca2 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -103,12 +103,12 @@ class BotSource(Cog): else: description = source_object.short_doc - title = source_object.qualified_name + title = f"Command: {source_object.qualified_name}" elif isinstance(source_object, str): title = f"Tag: {source_object.split('/')[-1].split('.')[0]}" description = "" else: - title = source_object.qualified_name + title = f"Cog: {source_object.qualified_name}" description = source_object.description.splitlines()[0] embed = Embed(title=title, description=description) -- cgit v1.2.3 From 0007fb7e50f273108e70bcafbd736bfc75ce3e51 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 11:59:44 +0300 Subject: Source: In converter move cog checking before command --- bot/cogs/source.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 9e6109ca2..a5f90e490 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -28,14 +28,14 @@ class SourceConverter(Converter): else: return f"/bot/bot/resources/tags/{tag}.md" - cmd = ctx.bot.get_command(argument) - if cmd: - return cmd - cog = ctx.bot.get_cog(argument) if cog: return cog + cmd = ctx.bot.get_command(argument) + if cmd: + return cmd + raise BadArgument(f"Unable to convert `{argument}` to valid command or Cog.") -- cgit v1.2.3 From aa83e72bd28f822c6ba84d73c5be05c6aea5d59b Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 12:01:26 +0300 Subject: Source: Simplify imports --- bot/cogs/source.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index a5f90e490..5668ab6c6 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -3,18 +3,18 @@ from pathlib import Path from typing import Optional, Tuple, Union from discord import Embed -from discord.ext.commands import BadArgument, Cog, Command, Context, Converter, HelpCommand, command +from discord.ext import commands from bot.bot import Bot from bot.constants import URLs -SourceType = Union[HelpCommand, Command, Cog, str] +SourceType = Union[commands.HelpCommand, commands.Command, commands.Cog, str] -class SourceConverter(Converter): +class SourceConverter(commands.Converter): """Convert an argument into a help command, tag, command, or cog.""" - async def convert(self, ctx: Context, argument: str) -> SourceType: + async def convert(self, ctx: commands.Context, argument: str) -> SourceType: """Convert argument into source object.""" if argument.lower().startswith("help"): return ctx.bot.help_command @@ -36,17 +36,17 @@ class SourceConverter(Converter): if cmd: return cmd - raise BadArgument(f"Unable to convert `{argument}` to valid command or Cog.") + raise commands.BadArgument(f"Unable to convert `{argument}` to valid command or Cog.") -class BotSource(Cog): +class BotSource(commands.Cog): """Displays information about the bot's source code.""" def __init__(self, bot: Bot): self.bot = bot - @command(name="source", aliases=("src",)) - async def source_command(self, ctx: Context, *, source_item: SourceConverter = None) -> None: + @commands.command(name="source", aliases=("src",)) + async def source_command(self, ctx: commands.Context, *, source_item: SourceConverter = None) -> None: """Display information and a GitHub link to the source code of a command, tag, or cog.""" if not source_item: embed = Embed(title="Bot's GitHub Repository") @@ -60,10 +60,10 @@ class BotSource(Cog): def get_source_link(self, source_item: SourceType) -> Tuple[str, str, Optional[int]]: """Build GitHub link of source item.""" - if isinstance(source_item, HelpCommand): + if isinstance(source_item, commands.HelpCommand): src = type(source_item) filename = inspect.getsourcefile(src) - elif isinstance(source_item, Command): + elif isinstance(source_item, commands.Command): if source_item.cog_name == "Alias": cmd_name = source_item.callback.__name__.replace("_alias", "") cmd = self.bot.get_command(cmd_name.replace("_", " ")) @@ -92,10 +92,10 @@ class BotSource(Cog): async def build_embed(self, link: str, source_object: SourceType, loc: str, first_line: Optional[int]) -> Embed: """Build embed based on source object.""" - if isinstance(source_object, HelpCommand): + if isinstance(source_object, commands.HelpCommand): title = "Help Command" description = source_object.__doc__.splitlines()[1] - elif isinstance(source_object, Command): + elif isinstance(source_object, commands.Command): if source_object.cog_name == "Alias": cmd_name = source_object.callback.__name__.replace("_alias", "") cmd = self.bot.get_command(cmd_name.replace("_", " ")) -- cgit v1.2.3 From 30510d6cfd877e5441022b8a8d893871fbf2a0a9 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 12:16:26 +0300 Subject: Source: Show aliases on title of command source embed --- bot/cogs/source.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 5668ab6c6..8fd8cbed4 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -103,7 +103,8 @@ class BotSource(commands.Cog): else: description = source_object.short_doc - title = f"Command: {source_object.qualified_name}" + aliases_string = f" (or {', '.join(source_object.aliases)})" if source_object.aliases else "" + title = f"Command: {source_object.qualified_name}{aliases_string}" elif isinstance(source_object, str): title = f"Tag: {source_object.split('/')[-1].split('.')[0]}" description = "" -- cgit v1.2.3 From 2c0cb510219a27a875628ff4453be2ba7f0a9d7f Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 12:17:10 +0300 Subject: Source: Include tag into converter's `BadArgument` raising --- bot/cogs/source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 8fd8cbed4..a3922297a 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -36,7 +36,7 @@ class SourceConverter(commands.Converter): if cmd: return cmd - raise commands.BadArgument(f"Unable to convert `{argument}` to valid command or Cog.") + raise commands.BadArgument(f"Unable to convert `{argument}` to valid command, tag, or Cog.") class BotSource(commands.Cog): -- cgit v1.2.3 From 00f52a15c67fa2a8dc9dc87769ba56cff9e2cdf4 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 1 Jun 2020 07:55:49 +0300 Subject: Tags: Add tag file location storage to cache --- bot/cogs/tags.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 6f03a3475..571c0ed28 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -47,6 +47,7 @@ class Tags(Cog): "description": file.read_text(encoding="utf8"), }, "restricted_to": "developers", + "location": str(file) } # Convert to a list to allow negative indexing. -- cgit v1.2.3 From 9f93a40bb8d8bd7d0538465f9d4eda79d02e540c Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 1 Jun 2020 08:11:41 +0300 Subject: Source: Simplify tags name and location parsing --- bot/cogs/source.py | 22 ++++++++++++++-------- bot/cogs/tags.py | 2 +- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index a3922297a..e01209c28 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -21,12 +21,8 @@ class SourceConverter(commands.Converter): tags_cog = ctx.bot.get_cog("Tags") - if argument.lower() in tags_cog._cache: - tag = argument.lower() - if tags_cog._cache[tag]["restricted_to"] != "developers": - return f"/bot/bot/resources/tags/{tags_cog._cache[tag]['restricted_to']}/{tag}.md" - else: - return f"/bot/bot/resources/tags/{tag}.md" + if tags_cog and argument.lower() in tags_cog._cache: + return argument.lower() cog = ctx.bot.get_cog(argument) if cog: @@ -56,6 +52,12 @@ class BotSource(commands.Cog): return url, location, first_line = self.get_source_link(source_item) + + # There is no URL only when bot can't fetch Tags cog + if not url: + await ctx.send("Unable to get `Tags` cog.") + return + await ctx.send(embed=await self.build_embed(url, source_item, location, first_line)) def get_source_link(self, source_item: SourceType) -> Tuple[str, str, Optional[int]]: @@ -73,7 +75,11 @@ class BotSource(commands.Cog): src = source_item.callback.__code__ filename = src.co_filename elif isinstance(source_item, str): - filename = source_item + tags_cog = self.bot.get_cog("Tags") + if not tags_cog: + return "", "", None + + filename = tags_cog._cache[source_item]["location"] else: src = type(source_item) filename = inspect.getsourcefile(src) @@ -106,7 +112,7 @@ class BotSource(commands.Cog): aliases_string = f" (or {', '.join(source_object.aliases)})" if source_object.aliases else "" title = f"Command: {source_object.qualified_name}{aliases_string}" elif isinstance(source_object, str): - title = f"Tag: {source_object.split('/')[-1].split('.')[0]}" + title = f"Tag: {source_object}" description = "" else: title = f"Cog: {source_object.qualified_name}" diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 571c0ed28..3d76c5c08 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -47,7 +47,7 @@ class Tags(Cog): "description": file.read_text(encoding="utf8"), }, "restricted_to": "developers", - "location": str(file) + "location": f"/bot/{file}" } # Convert to a list to allow negative indexing. -- cgit v1.2.3 From c397b871fbce903f251abd32662d40d33a95e0de Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 1 Jun 2020 08:16:59 +0300 Subject: Source: Move calling `get_source_link` to `build_embed` --- bot/cogs/source.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index e01209c28..00a5b344b 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -51,14 +51,11 @@ class BotSource(commands.Cog): await ctx.send(embed=embed) return - url, location, first_line = self.get_source_link(source_item) + embed = await self.build_embed(source_item, ctx) - # There is no URL only when bot can't fetch Tags cog - if not url: - await ctx.send("Unable to get `Tags` cog.") - return - - await ctx.send(embed=await self.build_embed(url, source_item, location, first_line)) + # When embed don't exist, then there was error and this is already handled. + if embed: + await ctx.send(embed=await self.build_embed(source_item, ctx)) def get_source_link(self, source_item: SourceType) -> Tuple[str, str, Optional[int]]: """Build GitHub link of source item.""" @@ -96,8 +93,15 @@ class BotSource(commands.Cog): return url, file_location, first_line_no or None - async def build_embed(self, link: str, source_object: SourceType, loc: str, first_line: Optional[int]) -> Embed: + async def build_embed(self, source_object: SourceType, ctx: commands.Context) -> Optional[Embed]: """Build embed based on source object.""" + url, location, first_line = self.get_source_link(source_object) + + # There is no URL only when bot can't fetch Tags cog + if not url: + await ctx.send("Unable to get `Tags` cog.") + return + if isinstance(source_object, commands.HelpCommand): title = "Help Command" description = source_object.__doc__.splitlines()[1] @@ -119,9 +123,9 @@ class BotSource(commands.Cog): description = source_object.description.splitlines()[0] embed = Embed(title=title, description=description) - embed.add_field(name="Source Code", value=f"[Go to GitHub]({link})") + embed.add_field(name="Source Code", value=f"[Go to GitHub]({url})") line_text = f":{first_line}" if first_line else "" - embed.set_footer(text=f"{loc}{line_text}") + embed.set_footer(text=f"{location}{line_text}") return embed -- cgit v1.2.3 From 18968d27ae5bc3b004c881a0c78bcfb305371158 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 1 Jun 2020 08:18:27 +0300 Subject: Source: Update `get_source_link` docstring --- bot/cogs/source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 00a5b344b..50fd4599d 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -58,7 +58,7 @@ class BotSource(commands.Cog): await ctx.send(embed=await self.build_embed(source_item, ctx)) def get_source_link(self, source_item: SourceType) -> Tuple[str, str, Optional[int]]: - """Build GitHub link of source item.""" + """Build GitHub link of source item, return this link, file location and first line number.""" if isinstance(source_item, commands.HelpCommand): src = type(source_item) filename = inspect.getsourcefile(src) -- cgit v1.2.3 From ea937e5a69b4cf233216510c96800bdf5940ff16 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 1 Jun 2020 08:19:21 +0300 Subject: Source: Remove showing aliases for commands --- bot/cogs/source.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 50fd4599d..57ae17f77 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -113,8 +113,7 @@ class BotSource(commands.Cog): else: description = source_object.short_doc - aliases_string = f" (or {', '.join(source_object.aliases)})" if source_object.aliases else "" - title = f"Command: {source_object.qualified_name}{aliases_string}" + title = f"Command: {source_object.qualified_name}" elif isinstance(source_object, str): title = f"Tag: {source_object}" description = "" -- cgit v1.2.3 From 8429c2284901675297c78f00bf0e5a6b15d80e31 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 1 Jun 2020 13:17:40 +0300 Subject: Source: Refactor Tags cog missing handling --- bot/cogs/source.py | 36 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 57ae17f77..32a78a0c0 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -8,7 +8,7 @@ from discord.ext import commands from bot.bot import Bot from bot.constants import URLs -SourceType = Union[commands.HelpCommand, commands.Command, commands.Cog, str] +SourceType = Union[commands.HelpCommand, commands.Command, commands.Cog, str, commands.ExtensionNotLoaded] class SourceConverter(commands.Converter): @@ -19,11 +19,6 @@ class SourceConverter(commands.Converter): if argument.lower().startswith("help"): return ctx.bot.help_command - tags_cog = ctx.bot.get_cog("Tags") - - if tags_cog and argument.lower() in tags_cog._cache: - return argument.lower() - cog = ctx.bot.get_cog(argument) if cog: return cog @@ -32,6 +27,14 @@ class SourceConverter(commands.Converter): if cmd: return cmd + tags_cog = ctx.bot.get_cog("Tags") + + if not tags_cog: + await ctx.send("Unable to get `Tags` cog.") + return commands.ExtensionNotLoaded("bot.cogs.tags") + elif argument.lower() in tags_cog._cache: + return argument.lower() + raise commands.BadArgument(f"Unable to convert `{argument}` to valid command, tag, or Cog.") @@ -44,6 +47,10 @@ class BotSource(commands.Cog): @commands.command(name="source", aliases=("src",)) async def source_command(self, ctx: commands.Context, *, source_item: SourceConverter = None) -> None: """Display information and a GitHub link to the source code of a command, tag, or cog.""" + # When we have problem to get Tags cog, exit early + if isinstance(source_item, commands.ExtensionNotLoaded): + return + if not source_item: embed = Embed(title="Bot's GitHub Repository") embed.add_field(name="Repository", value=f"[Go to GitHub]({URLs.github_bot_repo})") @@ -51,11 +58,8 @@ class BotSource(commands.Cog): await ctx.send(embed=embed) return - embed = await self.build_embed(source_item, ctx) - - # When embed don't exist, then there was error and this is already handled. - if embed: - await ctx.send(embed=await self.build_embed(source_item, ctx)) + embed = await self.build_embed(source_item) + await ctx.send(embed=embed) def get_source_link(self, source_item: SourceType) -> Tuple[str, str, Optional[int]]: """Build GitHub link of source item, return this link, file location and first line number.""" @@ -73,9 +77,6 @@ class BotSource(commands.Cog): filename = src.co_filename elif isinstance(source_item, str): tags_cog = self.bot.get_cog("Tags") - if not tags_cog: - return "", "", None - filename = tags_cog._cache[source_item]["location"] else: src = type(source_item) @@ -93,15 +94,10 @@ class BotSource(commands.Cog): return url, file_location, first_line_no or None - async def build_embed(self, source_object: SourceType, ctx: commands.Context) -> Optional[Embed]: + async def build_embed(self, source_object: SourceType) -> Optional[Embed]: """Build embed based on source object.""" url, location, first_line = self.get_source_link(source_object) - # There is no URL only when bot can't fetch Tags cog - if not url: - await ctx.send("Unable to get `Tags` cog.") - return - if isinstance(source_object, commands.HelpCommand): title = "Help Command" description = source_object.__doc__.splitlines()[1] -- cgit v1.2.3 From 780ed87ef3d4f24e45d2cba8020342c1195f7801 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Mon, 8 Jun 2020 18:31:09 +0200 Subject: Incidents: add incidents module & new ext boilerplate --- bot/cogs/moderation/__init__.py | 4 +++- bot/cogs/moderation/incidents.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 bot/cogs/moderation/incidents.py diff --git a/bot/cogs/moderation/__init__.py b/bot/cogs/moderation/__init__.py index 6880ca1bd..4455705f7 100644 --- a/bot/cogs/moderation/__init__.py +++ b/bot/cogs/moderation/__init__.py @@ -1,4 +1,5 @@ from bot.bot import Bot +from .incidents import Incidents from .infractions import Infractions from .management import ModManagement from .modlog import ModLog @@ -7,7 +8,8 @@ from .superstarify import Superstarify def setup(bot: Bot) -> None: - """Load the Infractions, ModManagement, ModLog, Silence, and Superstarify cogs.""" + """Load the Incidents, Infractions, ModManagement, ModLog, Silence, and Superstarify cogs.""" + bot.add_cog(Incidents(bot)) bot.add_cog(Infractions(bot)) bot.add_cog(ModLog(bot)) bot.add_cog(ModManagement(bot)) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py new file mode 100644 index 000000000..2ff1e949a --- /dev/null +++ b/bot/cogs/moderation/incidents.py @@ -0,0 +1,14 @@ +import logging + +from discord.ext.commands import Cog + +from bot.bot import Bot + +log = logging.getLogger(__name__) + + +class Incidents(Cog): + """Automation for the #incidents channel.""" + + def __init__(self, bot: Bot) -> None: + self.bot = bot -- cgit v1.2.3 From 29ab6dc350f0063bcac2218aee7c9170e83f980a Mon Sep 17 00:00:00 2001 From: kwzrd Date: Mon, 8 Jun 2020 23:33:58 +0200 Subject: Incidents: add new emoji constants --- bot/constants.py | 4 ++++ config-default.yml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index b31a9c99e..02b82cf23 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -271,6 +271,10 @@ class Emojis(metaclass=YAMLGetter): status_idle: str status_dnd: str + incident_actioned: str + incident_unactioned: str + incident_investigating: str + failmail: str trashcan: str diff --git a/config-default.yml b/config-default.yml index 2c85f5ef3..c59abdc39 100644 --- a/config-default.yml +++ b/config-default.yml @@ -38,6 +38,10 @@ style: status_dnd: "<:status_dnd:470326272082313216>" status_offline: "<:status_offline:470326266537705472>" + incident_actioned: "<:incident_actioned:719645530128646266>" + incident_unactioned: "<:incident_unactioned:719645583245180960>" + incident_investigating: "<:incident_investigating:719645658671480924>" + failmail: "<:failmail:633660039931887616>" trashcan: "<:trashcan:637136429717389331>" -- cgit v1.2.3 From 0d3af0d52b23b3390aadf37a82e905e8ee529a90 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Mon, 8 Jun 2020 23:36:20 +0200 Subject: Incidents: create Signal enum & link members with emojis --- bot/cogs/moderation/incidents.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 2ff1e949a..baceddf0c 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -1,12 +1,22 @@ import logging +from enum import Enum from discord.ext.commands import Cog from bot.bot import Bot +from bot.constants import Emojis log = logging.getLogger(__name__) +class Signal(Enum): + """Recognized incident status signals.""" + + ACTIONED = Emojis.incident_actioned + NOT_ACTIONED = Emojis.incident_unactioned + INVESTIGATING = Emojis.incident_investigating + + class Incidents(Cog): """Automation for the #incidents channel.""" -- cgit v1.2.3 From 78782868040d1b2ca0b655efc4123b3d9b6bfda3 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 23 May 2020 10:23:45 +0300 Subject: Jam Tests: Created base test layout --- tests/bot/cogs/test_jams.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 tests/bot/cogs/test_jams.py diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py new file mode 100644 index 000000000..33dee593e --- /dev/null +++ b/tests/bot/cogs/test_jams.py @@ -0,0 +1,14 @@ +import unittest + +from bot.constants import Roles +from tests.helpers import MockBot, MockContext, MockMember, MockRole + + +class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): + """Tests for `createteam` command.""" + + def setUp(self): + self.bot = MockBot() + self.admin_role = MockRole(name="Admins", id=Roles.admins) + self.command_user = MockMember([self.admin_role]) + self.context = MockContext(bot=self.bot, author=self.command_user) -- cgit v1.2.3 From 6242fbdce8935c681fa575b1c208642fe9d2635b Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 23 May 2020 10:38:41 +0300 Subject: Jam Tests: Created tests for case when too small amount of members given --- tests/bot/cogs/test_jams.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index 33dee593e..3e71370c2 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -1,5 +1,7 @@ import unittest +from unittest.mock import patch +from bot.cogs.jams import CodeJams from bot.constants import Roles from tests.helpers import MockBot, MockContext, MockMember, MockRole @@ -11,4 +13,18 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): self.bot = MockBot() self.admin_role = MockRole(name="Admins", id=Roles.admins) self.command_user = MockMember([self.admin_role]) - self.context = MockContext(bot=self.bot, author=self.command_user) + self.ctx = MockContext(bot=self.bot, author=self.command_user) + self.cog = CodeJams(self.bot) + + @patch("bot.cogs.jams.utils") + async def test_too_small_amount_of_team_members_passed(self, utils_mock): + """Should `ctx.send` and exit early when too small amount of members.""" + for case in (1, 2): + with self.subTest(amount_of_members=case): + self.ctx.reset_mock() + utils_mock.reset_mock() + await self.cog.createteam( + self.cog, self.ctx, team_name="foo", members=(MockMember() for _ in range(case)) + ) + self.ctx.send.assert_awaited_once() + utils_mock.get.assert_not_called() -- cgit v1.2.3 From a9122b781191f93f5dd375b5c1d9e7744943b464 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 23 May 2020 10:46:08 +0300 Subject: Jam Tests: Created tests for removing duplicate team members --- tests/bot/cogs/test_jams.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index 3e71370c2..1cface1c1 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -28,3 +28,12 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): ) self.ctx.send.assert_awaited_once() utils_mock.get.assert_not_called() + + @patch("bot.cogs.jams.utils") + async def test_duplicate_members_provided(self, utils_mock): + """Should `ctx.send` and exit early because duplicate members provided and total there is only 1 member.""" + self.ctx.reset_mock() + member = MockMember() + await self.cog.createteam(self.cog, self.ctx, "foo", (member for _ in range(5))) + self.ctx.send.assert_awaited_once() + utils_mock.get.assert_not_called() -- cgit v1.2.3 From ebaac5988d7ff1558595008540eab5368312d170 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 23 May 2020 17:57:26 +0300 Subject: Jam Tests: Created test for category creating when not exist --- tests/bot/cogs/test_jams.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index 1cface1c1..2153178c3 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -3,7 +3,7 @@ from unittest.mock import patch from bot.cogs.jams import CodeJams from bot.constants import Roles -from tests.helpers import MockBot, MockContext, MockMember, MockRole +from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): @@ -13,7 +13,8 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): self.bot = MockBot() self.admin_role = MockRole(name="Admins", id=Roles.admins) self.command_user = MockMember([self.admin_role]) - self.ctx = MockContext(bot=self.bot, author=self.command_user) + self.guild = MockGuild([self.admin_role]) + self.ctx = MockContext(bot=self.bot, author=self.command_user, guild=self.guild) self.cog = CodeJams(self.bot) @patch("bot.cogs.jams.utils") @@ -37,3 +38,14 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): await self.cog.createteam(self.cog, self.ctx, "foo", (member for _ in range(5))) self.ctx.send.assert_awaited_once() utils_mock.get.assert_not_called() + + @patch("bot.cogs.jams.utils") + async def test_category_dont_exist(self, utils_mock): + """Should create code jam category.""" + utils_mock.get.return_value = None + await self.cog.createteam(self.cog, self.ctx, "foo", (MockMember() for _ in range(5))) + self.ctx.guild.create_category_channel.assert_awaited_once() + category_overwrites = self.ctx.guild.create_category_channel.call_args[1]["overwrites"] + + self.assertFalse(category_overwrites[self.ctx.guild.default_role].read_messages) + self.assertTrue(category_overwrites[self.ctx.guild.me].read_messages) -- cgit v1.2.3 From 14d4eda8b1e7839b286402091ac060d3c869f447 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 23 May 2020 17:58:38 +0300 Subject: Jam Tests: Added utils.get assert to category creating test --- tests/bot/cogs/test_jams.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index 2153178c3..f5f87761b 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -44,6 +44,7 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): """Should create code jam category.""" utils_mock.get.return_value = None await self.cog.createteam(self.cog, self.ctx, "foo", (MockMember() for _ in range(5))) + utils_mock.get.assert_called_once() self.ctx.guild.create_category_channel.assert_awaited_once() category_overwrites = self.ctx.guild.create_category_channel.call_args[1]["overwrites"] -- cgit v1.2.3 From 464c4bbb53101d4456314bf7a40243337525d514 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 23 May 2020 18:03:17 +0300 Subject: Jam Tests: Created test that make sure when category exist, don't create --- tests/bot/cogs/test_jams.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index f5f87761b..1ce71a942 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -50,3 +50,11 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(category_overwrites[self.ctx.guild.default_role].read_messages) self.assertTrue(category_overwrites[self.ctx.guild.me].read_messages) + + @patch("bot.cogs.jams.utils") + async def test_category_channel_exist(self, utils_mock): + """Should not try to create category channel.""" + utils_mock.return_value = "foo" + await self.cog.createteam(self.cog, self.ctx, "bar", (MockMember() for _ in range(5))) + utils_mock.get.assert_called_once() + self.ctx.guild.create_category_channel.assert_not_awaited() -- cgit v1.2.3 From a63545510f392cf3e36e310b68792177a178b769 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 23 May 2020 18:08:29 +0300 Subject: Jam Tests: Created test for creating text channel for team --- tests/bot/cogs/test_jams.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index 1ce71a942..9d26628ff 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -58,3 +58,8 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): await self.cog.createteam(self.cog, self.ctx, "bar", (MockMember() for _ in range(5))) utils_mock.get.assert_called_once() self.ctx.guild.create_category_channel.assert_not_awaited() + + async def test_team_text_channel_creation(self): + """Should create text channel for team.""" + await self.cog.createteam(self.cog, self.ctx, "bar", (MockMember() for _ in range(5))) + self.ctx.guild.create_text_channel.assert_awaited_once() -- cgit v1.2.3 From 3df28c1b2a64bee3a52442fe42decaa960c45fde Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 23 May 2020 18:18:09 +0300 Subject: Jam Tests: Created test for channel overwrites --- tests/bot/cogs/test_jams.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index 9d26628ff..d21c5ea29 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -63,3 +63,27 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): """Should create text channel for team.""" await self.cog.createteam(self.cog, self.ctx, "bar", (MockMember() for _ in range(5))) self.ctx.guild.create_text_channel.assert_awaited_once() + + async def test_channel_overwrites(self): + """Should have correct permission overwrites for users and roles.""" + leader = MockMember() + members = [leader] + [MockMember() for _ in range(4)] + await self.cog.createteam(self.cog, self.ctx, "foo", members) + overwrites = self.ctx.guild.create_text_channel.call_args[1]["overwrites"] + + # Leader permission overwrites + self.assertTrue(overwrites[leader].manage_messages) + self.assertTrue(overwrites[leader].read_messages) + self.assertTrue(overwrites[leader].manage_webhooks) + self.assertTrue(overwrites[leader].connect) + + # Other members permission overwrites + for member in members[1:]: + self.assertTrue(overwrites[member].read_messages) + self.assertTrue(overwrites[member].connect) + + # Everyone and verified role overwrite + self.assertFalse(overwrites[self.ctx.guild.default_role].read_messages) + self.assertFalse(overwrites[self.ctx.guild.default_role].connect) + self.assertFalse(overwrites[self.ctx.guild.get_role(Roles.verified)].read_messages) + self.assertFalse(overwrites[self.ctx.guild.get_role(Roles.verified)].connect) -- cgit v1.2.3 From 6476d3ba6dfc28441d097aaa15a7c9e13f53f646 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 23 May 2020 18:22:02 +0300 Subject: Jam Tests: Make text channel creation test more specific --- tests/bot/cogs/test_jams.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index d21c5ea29..94c48b995 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -59,11 +59,18 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): utils_mock.get.assert_called_once() self.ctx.guild.create_category_channel.assert_not_awaited() - async def test_team_text_channel_creation(self): + @patch("bot.cogs.jams.utils") + async def test_team_text_channel_creation(self, utils_mock): """Should create text channel for team.""" + utils_mock.get.return_value = "foo" await self.cog.createteam(self.cog, self.ctx, "bar", (MockMember() for _ in range(5))) + # Make sure that we awaited function before getting call arguments self.ctx.guild.create_text_channel.assert_awaited_once() + # All other arguments is possible to get somewhere else except this + overwrites = self.ctx.guild.create_text_channel.call_args[1]["overwrites"] + self.ctx.guild.create_text_channel.assert_awaited_once_with("bar", overwrites=overwrites, category="foo") + async def test_channel_overwrites(self): """Should have correct permission overwrites for users and roles.""" leader = MockMember() -- cgit v1.2.3 From b1359f0ed37cdbbb6bae9dbbe92e3bf0db660636 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 23 May 2020 18:26:41 +0300 Subject: Jam Tests: Create test for team voice channel creating --- tests/bot/cogs/test_jams.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index 94c48b995..2e1419f8e 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -94,3 +94,15 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(overwrites[self.ctx.guild.default_role].connect) self.assertFalse(overwrites[self.ctx.guild.get_role(Roles.verified)].read_messages) self.assertFalse(overwrites[self.ctx.guild.get_role(Roles.verified)].connect) + + @patch("bot.cogs.jams.utils") + async def test_team_voice_channel_creation(self, utils_mock): + """Should create new voice channel for team.""" + utils_mock.get.return_value = "foo" + await self.cog.createteam(self.cog, self.ctx, "my-team", (MockMember() for _ in range(5))) + # Make sure that we awaited function before getting call arguments + self.ctx.guild.create_voice_channel.assert_awaited_once() + + # All other arguments is possible to get somewhere else except this + overwrites = self.ctx.guild.create_voice_channel.call_args[1]["overwrites"] + self.ctx.guild.create_voice_channel.assert_awaited_once_with("My Team", overwrites=overwrites, category="foo") -- cgit v1.2.3 From b5b05adc41e55dd58810608f4ac7ade6281cdf84 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 23 May 2020 18:37:27 +0300 Subject: Jam Tests: Create test for team jam roles adding --- tests/bot/cogs/test_jams.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index 2e1419f8e..16caa98c6 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -106,3 +106,17 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): # All other arguments is possible to get somewhere else except this overwrites = self.ctx.guild.create_voice_channel.call_args[1]["overwrites"] self.ctx.guild.create_voice_channel.assert_awaited_once_with("My Team", overwrites=overwrites, category="foo") + + async def test_jam_roles_adding(self): + """Should add team leader role to leader and jam role to every team member.""" + leader_role = MockRole(name="Team Leader") + jam_role = MockRole(name="Jammer") + self.ctx.guild.get_role.side_effect = [MockRole(), leader_role, jam_role] + + leader = MockMember() + members = [leader] + [MockMember() for _ in range(4)] + await self.cog.createteam(self.cog, self.ctx, "foo", members) + + leader.add_roles.assert_any_await(leader_role) + for member in members: + member.add_roles.assert_any_await(jam_role) -- cgit v1.2.3 From 76ad4d141027f6351e2feedc466c8acc805f671d Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 23 May 2020 18:39:13 +0300 Subject: Jam Tests: Create test for successful `ctx.send` calling --- tests/bot/cogs/test_jams.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index 16caa98c6..7db66ff11 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -120,3 +120,9 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): leader.add_roles.assert_any_await(leader_role) for member in members: member.add_roles.assert_any_await(jam_role) + + async def test_result_sending(self): + """Should call `ctx.send` when everything go right.""" + self.ctx.reset_mock() + await self.cog.createteam(self.cog, self.ctx, "foo", (MockMember() for _ in range(5))) + self.ctx.send.assert_awaited_once() -- cgit v1.2.3 From bbe4f137bd583d66a6bcb03102327bc6c586af86 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 23 May 2020 18:42:03 +0300 Subject: Jam Tests: Create test for `setup` function --- tests/bot/cogs/test_jams.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index 7db66ff11..2c5cef835 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -1,7 +1,7 @@ import unittest from unittest.mock import patch -from bot.cogs.jams import CodeJams +from bot.cogs.jams import CodeJams, setup from bot.constants import Roles from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole @@ -126,3 +126,13 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): self.ctx.reset_mock() await self.cog.createteam(self.cog, self.ctx, "foo", (MockMember() for _ in range(5))) self.ctx.send.assert_awaited_once() + + +class CodeJamSetup(unittest.TestCase): + """Test for `setup` function of `CodeJam` cog.""" + + def test_setup(self): + """Should call `bot.add_cog`.""" + bot = MockBot() + setup(bot) + bot.add_cog.assert_called_once() -- cgit v1.2.3 From 5ca860fb3b2bcb77ab8574d83e8159df471f0faf Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 11 Jun 2020 08:54:07 +0300 Subject: Jam Tests: Fix `test_result_sending` docstring Co-authored-by: Mark --- tests/bot/cogs/test_jams.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index 2c5cef835..51720d957 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -122,7 +122,7 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): member.add_roles.assert_any_await(jam_role) async def test_result_sending(self): - """Should call `ctx.send` when everything go right.""" + """Should call `ctx.send` when everything goes right.""" self.ctx.reset_mock() await self.cog.createteam(self.cog, self.ctx, "foo", (MockMember() for _ in range(5))) self.ctx.send.assert_awaited_once() -- cgit v1.2.3 From 28f33584b65b1f9d7e7254b4822d8896c7f19284 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 11 Jun 2020 09:26:43 +0300 Subject: Jam Tests: Use class member of patch instead decorator on most of tests --- tests/bot/cogs/test_jams.py | 40 +++++++++++++++++++--------------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index 51720d957..bf542458b 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -16,53 +16,52 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): self.guild = MockGuild([self.admin_role]) self.ctx = MockContext(bot=self.bot, author=self.command_user, guild=self.guild) self.cog = CodeJams(self.bot) + self.utils_mock = patch("bot.cogs.jams.utils").start() - @patch("bot.cogs.jams.utils") - async def test_too_small_amount_of_team_members_passed(self, utils_mock): + def tearDown(self): + self.utils_mock.stop() + + async def test_too_small_amount_of_team_members_passed(self): """Should `ctx.send` and exit early when too small amount of members.""" for case in (1, 2): with self.subTest(amount_of_members=case): self.ctx.reset_mock() - utils_mock.reset_mock() + self.utils_mock.reset_mock() await self.cog.createteam( self.cog, self.ctx, team_name="foo", members=(MockMember() for _ in range(case)) ) self.ctx.send.assert_awaited_once() - utils_mock.get.assert_not_called() + self.utils_mock.get.assert_not_called() - @patch("bot.cogs.jams.utils") - async def test_duplicate_members_provided(self, utils_mock): + async def test_duplicate_members_provided(self): """Should `ctx.send` and exit early because duplicate members provided and total there is only 1 member.""" self.ctx.reset_mock() member = MockMember() await self.cog.createteam(self.cog, self.ctx, "foo", (member for _ in range(5))) self.ctx.send.assert_awaited_once() - utils_mock.get.assert_not_called() + self.utils_mock.get.assert_not_called() - @patch("bot.cogs.jams.utils") - async def test_category_dont_exist(self, utils_mock): + async def test_category_dont_exist(self): """Should create code jam category.""" - utils_mock.get.return_value = None + self.utils_mock.get.return_value = None await self.cog.createteam(self.cog, self.ctx, "foo", (MockMember() for _ in range(5))) - utils_mock.get.assert_called_once() + self.utils_mock.get.assert_called_once() self.ctx.guild.create_category_channel.assert_awaited_once() category_overwrites = self.ctx.guild.create_category_channel.call_args[1]["overwrites"] self.assertFalse(category_overwrites[self.ctx.guild.default_role].read_messages) self.assertTrue(category_overwrites[self.ctx.guild.me].read_messages) - @patch("bot.cogs.jams.utils") - async def test_category_channel_exist(self, utils_mock): + async def test_category_channel_exist(self): """Should not try to create category channel.""" - utils_mock.return_value = "foo" + self.utils_mock.return_value = "foo" await self.cog.createteam(self.cog, self.ctx, "bar", (MockMember() for _ in range(5))) - utils_mock.get.assert_called_once() + self.utils_mock.get.assert_called_once() self.ctx.guild.create_category_channel.assert_not_awaited() - @patch("bot.cogs.jams.utils") - async def test_team_text_channel_creation(self, utils_mock): + async def test_team_text_channel_creation(self): """Should create text channel for team.""" - utils_mock.get.return_value = "foo" + self.utils_mock.get.return_value = "foo" await self.cog.createteam(self.cog, self.ctx, "bar", (MockMember() for _ in range(5))) # Make sure that we awaited function before getting call arguments self.ctx.guild.create_text_channel.assert_awaited_once() @@ -95,10 +94,9 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(overwrites[self.ctx.guild.get_role(Roles.verified)].read_messages) self.assertFalse(overwrites[self.ctx.guild.get_role(Roles.verified)].connect) - @patch("bot.cogs.jams.utils") - async def test_team_voice_channel_creation(self, utils_mock): + async def test_team_voice_channel_creation(self): """Should create new voice channel for team.""" - utils_mock.get.return_value = "foo" + self.utils_mock.get.return_value = "foo" await self.cog.createteam(self.cog, self.ctx, "my-team", (MockMember() for _ in range(5))) # Make sure that we awaited function before getting call arguments self.ctx.guild.create_voice_channel.assert_awaited_once() -- cgit v1.2.3 From 930eaebc185806c25335d9a83c5e0e7f3fddedf4 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 11 Jun 2020 09:32:44 +0300 Subject: Jams: Move category checking and creation to another function --- bot/cogs/jams.py | 38 ++++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py index 1d062b0c2..0ebff5428 100644 --- a/bot/cogs/jams.py +++ b/bot/cogs/jams.py @@ -1,6 +1,6 @@ import logging -from discord import Member, PermissionOverwrite, utils +from discord import CategoryChannel, Member, PermissionOverwrite, utils from discord.ext import commands from more_itertools import unique_everseen @@ -40,21 +40,7 @@ class CodeJams(commands.Cog): ) return - code_jam_category = utils.get(ctx.guild.categories, name="Code Jam") - - if code_jam_category is None: - log.info("Code Jam category not found, creating it.") - - category_overwrites = { - ctx.guild.default_role: PermissionOverwrite(read_messages=False), - ctx.guild.me: PermissionOverwrite(read_messages=True) - } - - code_jam_category = await ctx.guild.create_category_channel( - "Code Jam", - overwrites=category_overwrites, - reason="It's code jam time!" - ) + code_jam_category = await self.get_category(ctx) # First member is always the team leader team_channel_overwrites = { @@ -108,6 +94,26 @@ class CodeJams(commands.Cog): f"**Team Members:** {' '.join(member.mention for member in members[1:])}" ) + async def get_category(self, ctx: commands.Context) -> CategoryChannel: + """Create Code Jam category when this don't exist and return this.""" + code_jam_category = utils.get(ctx.guild.categories, name="Code Jam") + + if code_jam_category is None: + log.info("Code Jam category not found, creating it.") + + category_overwrites = { + ctx.guild.default_role: PermissionOverwrite(read_messages=False), + ctx.guild.me: PermissionOverwrite(read_messages=True) + } + + code_jam_category = await ctx.guild.create_category_channel( + "Code Jam", + overwrites=category_overwrites, + reason="It's code jam time!" + ) + + return code_jam_category + def setup(bot: Bot) -> None: """Load the CodeJams cog.""" -- cgit v1.2.3 From 4b194e288aaca445947ad7df2c2202989f76a076 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 11 Jun 2020 09:36:38 +0300 Subject: Jams: Move overwrites generation to outside of command --- bot/cogs/jams.py | 50 ++++++++++++++++++++++++++++---------------------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py index 0ebff5428..2b4575d5f 100644 --- a/bot/cogs/jams.py +++ b/bot/cogs/jams.py @@ -1,4 +1,5 @@ import logging +import typing as t from discord import CategoryChannel, Member, PermissionOverwrite, utils from discord.ext import commands @@ -41,28 +42,7 @@ class CodeJams(commands.Cog): return code_jam_category = await self.get_category(ctx) - - # First member is always the team leader - team_channel_overwrites = { - members[0]: PermissionOverwrite( - manage_messages=True, - read_messages=True, - manage_webhooks=True, - connect=True - ), - ctx.guild.default_role: PermissionOverwrite(read_messages=False, connect=False), - ctx.guild.get_role(Roles.verified): PermissionOverwrite( - read_messages=False, - connect=False - ) - } - - # Rest of members should just have read_messages - for member in members[1:]: - team_channel_overwrites[member] = PermissionOverwrite( - read_messages=True, - connect=True - ) + team_channel_overwrites = self.get_overwrites(members, ctx) # Create a text channel for the team team_channel = await ctx.guild.create_text_channel( @@ -114,6 +94,32 @@ class CodeJams(commands.Cog): return code_jam_category + def get_overwrites(self, members: t.List[Member], ctx: commands.Context) -> t.Dict[Member, PermissionOverwrite]: + """Get Code Jam team channels permission overwrites.""" + # First member is always the team leader + team_channel_overwrites = { + members[0]: PermissionOverwrite( + manage_messages=True, + read_messages=True, + manage_webhooks=True, + connect=True + ), + ctx.guild.default_role: PermissionOverwrite(read_messages=False, connect=False), + ctx.guild.get_role(Roles.verified): PermissionOverwrite( + read_messages=False, + connect=False + ) + } + + # Rest of members should just have read_messages + for member in members[1:]: + team_channel_overwrites[member] = PermissionOverwrite( + read_messages=True, + connect=True + ) + + return team_channel_overwrites + def setup(bot: Bot) -> None: """Load the CodeJams cog.""" -- cgit v1.2.3 From e8ef1b0f7ae9426da8be66fdeb6cecc81870c070 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 11 Jun 2020 09:44:20 +0300 Subject: Jams: Move channels creation to new function instead inside command --- bot/cogs/jams.py | 45 ++++++++++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py index 2b4575d5f..9089dcec2 100644 --- a/bot/cogs/jams.py +++ b/bot/cogs/jams.py @@ -41,24 +41,7 @@ class CodeJams(commands.Cog): ) return - code_jam_category = await self.get_category(ctx) - team_channel_overwrites = self.get_overwrites(members, ctx) - - # Create a text channel for the team - team_channel = await ctx.guild.create_text_channel( - team_name, - overwrites=team_channel_overwrites, - category=code_jam_category - ) - - # Create a voice channel for the team - team_voice_name = " ".join(team_name.split("-")).title() - - await ctx.guild.create_voice_channel( - team_voice_name, - overwrites=team_channel_overwrites, - category=code_jam_category - ) + team_channel = await self.create_channels(ctx, team_name, members) # Assign team leader role await members[0].add_roles(ctx.guild.get_role(Roles.team_leaders)) @@ -69,7 +52,7 @@ class CodeJams(commands.Cog): await member.add_roles(jammer_role) await ctx.send( - f":ok_hand: Team created: {team_channel.mention}\n" + f":ok_hand: Team created: {team_channel}\n" f"**Team Leader:** {members[0].mention}\n" f"**Team Members:** {' '.join(member.mention for member in members[1:])}" ) @@ -120,6 +103,30 @@ class CodeJams(commands.Cog): return team_channel_overwrites + async def create_channels(self, ctx: commands.Context, team_name: str, members: t.List[Member]) -> str: + """Create team text and voice channel. Return name of text channel.""" + # Get permission overwrites and category + team_channel_overwrites = self.get_overwrites(members, ctx) + code_jam_category = await self.get_category(ctx) + + # Create a text channel for the team + team_channel = await ctx.guild.create_text_channel( + team_name, + overwrites=team_channel_overwrites, + category=code_jam_category + ) + + # Create a voice channel for the team + team_voice_name = " ".join(team_name.split("-")).title() + + await ctx.guild.create_voice_channel( + team_voice_name, + overwrites=team_channel_overwrites, + category=code_jam_category + ) + + return str(team_channel) + def setup(bot: Bot) -> None: """Load the CodeJams cog.""" -- cgit v1.2.3 From 9719612995f2cd7e5b976031bbfd6a1591d76f23 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 11 Jun 2020 09:45:31 +0300 Subject: Jams: Change return plain text to channel mention in `create_channels` --- bot/cogs/jams.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py index 9089dcec2..5576adb2d 100644 --- a/bot/cogs/jams.py +++ b/bot/cogs/jams.py @@ -125,7 +125,7 @@ class CodeJams(commands.Cog): category=code_jam_category ) - return str(team_channel) + return team_channel.mention def setup(bot: Bot) -> None: -- cgit v1.2.3 From 8419531b899fbebb2a1f3378b4e1a98a0f45d812 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 11 Jun 2020 10:49:47 +0300 Subject: Jams: Move roles adding to another function from inside of command --- bot/cogs/jams.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py index 5576adb2d..4173f10fd 100644 --- a/bot/cogs/jams.py +++ b/bot/cogs/jams.py @@ -42,14 +42,7 @@ class CodeJams(commands.Cog): return team_channel = await self.create_channels(ctx, team_name, members) - - # Assign team leader role - await members[0].add_roles(ctx.guild.get_role(Roles.team_leaders)) - - # Assign rest of roles - jammer_role = ctx.guild.get_role(Roles.jammers) - for member in members: - await member.add_roles(jammer_role) + await self.add_roles(ctx, members) await ctx.send( f":ok_hand: Team created: {team_channel}\n" @@ -127,6 +120,16 @@ class CodeJams(commands.Cog): return team_channel.mention + async def add_roles(self, ctx: commands.Context, members: t.List[Member]) -> None: + """Assign team leader and jammer roles.""" + # Assign team leader role + await members[0].add_roles(ctx.guild.get_role(Roles.team_leaders)) + + # Assign rest of roles + jammer_role = ctx.guild.get_role(Roles.jammers) + for member in members: + await member.add_roles(jammer_role) + def setup(bot: Bot) -> None: """Load the CodeJams cog.""" -- cgit v1.2.3 From d3d031fab124b8f147674a2560ae402d469ddb4e Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 11 Jun 2020 11:13:10 +0300 Subject: Jams: Convert some functions to staticmethod --- bot/cogs/jams.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py index 4173f10fd..16dda35c8 100644 --- a/bot/cogs/jams.py +++ b/bot/cogs/jams.py @@ -50,7 +50,8 @@ class CodeJams(commands.Cog): f"**Team Members:** {' '.join(member.mention for member in members[1:])}" ) - async def get_category(self, ctx: commands.Context) -> CategoryChannel: + @staticmethod + async def get_category(ctx: commands.Context) -> CategoryChannel: """Create Code Jam category when this don't exist and return this.""" code_jam_category = utils.get(ctx.guild.categories, name="Code Jam") @@ -70,7 +71,8 @@ class CodeJams(commands.Cog): return code_jam_category - def get_overwrites(self, members: t.List[Member], ctx: commands.Context) -> t.Dict[Member, PermissionOverwrite]: + @staticmethod + def get_overwrites(members: t.List[Member], ctx: commands.Context) -> t.Dict[Member, PermissionOverwrite]: """Get Code Jam team channels permission overwrites.""" # First member is always the team leader team_channel_overwrites = { @@ -120,7 +122,8 @@ class CodeJams(commands.Cog): return team_channel.mention - async def add_roles(self, ctx: commands.Context, members: t.List[Member]) -> None: + @staticmethod + async def add_roles(ctx: commands.Context, members: t.List[Member]) -> None: """Assign team leader and jammer roles.""" # Assign team leader role await members[0].add_roles(ctx.guild.get_role(Roles.team_leaders)) -- cgit v1.2.3 From 1c860606a122ff1378cb55e228312acb2bb2d49e Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 11 Jun 2020 11:16:50 +0300 Subject: Jam Tests: Make early exiting test more secure --- tests/bot/cogs/test_jams.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index bf542458b..98fa12f66 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -1,5 +1,5 @@ import unittest -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from bot.cogs.jams import CodeJams, setup from bot.constants import Roles @@ -25,13 +25,17 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): """Should `ctx.send` and exit early when too small amount of members.""" for case in (1, 2): with self.subTest(amount_of_members=case): + self.cog.create_channels = AsyncMock() + self.cog.add_roles = AsyncMock() + self.ctx.reset_mock() self.utils_mock.reset_mock() await self.cog.createteam( self.cog, self.ctx, team_name="foo", members=(MockMember() for _ in range(case)) ) self.ctx.send.assert_awaited_once() - self.utils_mock.get.assert_not_called() + self.cog.create_channels.assert_not_awaited() + self.cog.add_roles.assert_not_awaited() async def test_duplicate_members_provided(self): """Should `ctx.send` and exit early because duplicate members provided and total there is only 1 member.""" -- cgit v1.2.3 From fd05997c1aa9054024ad62dc0cbf19c1a296f4b7 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 11 Jun 2020 11:22:52 +0300 Subject: Jam Tests: Add more assertions to result message sending test --- tests/bot/cogs/test_jams.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index 98fa12f66..4307d7deb 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -126,8 +126,14 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): async def test_result_sending(self): """Should call `ctx.send` when everything goes right.""" self.ctx.reset_mock() - await self.cog.createteam(self.cog, self.ctx, "foo", (MockMember() for _ in range(5))) + members = [MockMember() for _ in range(5)] + await self.cog.createteam(self.cog, self.ctx, "foo", members) self.ctx.send.assert_awaited_once() + sent_string = self.ctx.send.call_args[0][0] + + self.assertIn(str(self.ctx.guild.create_text_channel.return_value.mention), sent_string) + self.assertIn(members[0].mention, sent_string) + self.assertIn(" ".join(member.mention for member in members[1:]), sent_string) class CodeJamSetup(unittest.TestCase): -- cgit v1.2.3 From fa4783c5e15709625e21d6a1aa766664eb2423e2 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 11 Jun 2020 11:24:27 +0300 Subject: Jam Tests: Apply recent changes to overwrites test --- tests/bot/cogs/test_jams.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index 4307d7deb..1cbff2674 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -78,8 +78,7 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): """Should have correct permission overwrites for users and roles.""" leader = MockMember() members = [leader] + [MockMember() for _ in range(4)] - await self.cog.createteam(self.cog, self.ctx, "foo", members) - overwrites = self.ctx.guild.create_text_channel.call_args[1]["overwrites"] + overwrites = self.cog.get_overwrites(members, self.ctx) # Leader permission overwrites self.assertTrue(overwrites[leader].manage_messages) -- cgit v1.2.3 From 0d2b61fd72f7b44d0534901c8f2e6ee3ccaad3f7 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 11 Jun 2020 11:36:11 +0300 Subject: Jam Tests: Merge text and voice channel creation tests --- tests/bot/cogs/test_jams.py | 45 ++++++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index 1cbff2674..54f906ed9 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -1,9 +1,9 @@ import unittest -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from bot.cogs.jams import CodeJams, setup from bot.constants import Roles -from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole +from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockTextChannel class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): @@ -63,17 +63,6 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): self.utils_mock.get.assert_called_once() self.ctx.guild.create_category_channel.assert_not_awaited() - async def test_team_text_channel_creation(self): - """Should create text channel for team.""" - self.utils_mock.get.return_value = "foo" - await self.cog.createteam(self.cog, self.ctx, "bar", (MockMember() for _ in range(5))) - # Make sure that we awaited function before getting call arguments - self.ctx.guild.create_text_channel.assert_awaited_once() - - # All other arguments is possible to get somewhere else except this - overwrites = self.ctx.guild.create_text_channel.call_args[1]["overwrites"] - self.ctx.guild.create_text_channel.assert_awaited_once_with("bar", overwrites=overwrites, category="foo") - async def test_channel_overwrites(self): """Should have correct permission overwrites for users and roles.""" leader = MockMember() @@ -97,16 +86,30 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(overwrites[self.ctx.guild.get_role(Roles.verified)].read_messages) self.assertFalse(overwrites[self.ctx.guild.get_role(Roles.verified)].connect) - async def test_team_voice_channel_creation(self): - """Should create new voice channel for team.""" + async def test_team_channels_creation(self): + """Should create new voice and text channel for team.""" self.utils_mock.get.return_value = "foo" - await self.cog.createteam(self.cog, self.ctx, "my-team", (MockMember() for _ in range(5))) - # Make sure that we awaited function before getting call arguments - self.ctx.guild.create_voice_channel.assert_awaited_once() + members = [MockMember() for _ in range(5)] - # All other arguments is possible to get somewhere else except this - overwrites = self.ctx.guild.create_voice_channel.call_args[1]["overwrites"] - self.ctx.guild.create_voice_channel.assert_awaited_once_with("My Team", overwrites=overwrites, category="foo") + self.cog.get_overwrites = MagicMock() + self.cog.get_category = AsyncMock() + self.ctx.guild.create_text_channel.return_value = MockTextChannel(mention="foobar-channel") + actual = await self.cog.create_channels(self.ctx, "my-team", members) + + self.assertEqual("foobar-channel", actual) + self.cog.get_overwrites.assert_called_once_with(members, self.ctx) + self.cog.get_category.assert_awaited_once_with(self.ctx) + + self.ctx.guild.create_text_channel.assert_awaited_once_with( + "my-team", + overwrites=self.cog.get_overwrites.return_value, + category=self.cog.get_category.return_value + ) + self.ctx.guild.create_voice_channel.assert_awaited_once_with( + "My Team", + overwrites=self.cog.get_overwrites.return_value, + category=self.cog.get_category.return_value + ) async def test_jam_roles_adding(self): """Should add team leader role to leader and jam role to every team member.""" -- cgit v1.2.3 From 4af2be7310141ab3ddc34a2184366c0d8212cdd5 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 11 Jun 2020 11:39:46 +0300 Subject: Jam Tests: Simplify and update `test_category_channel_exist` --- tests/bot/cogs/test_jams.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index 54f906ed9..ae3e35dbb 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -58,9 +58,7 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): async def test_category_channel_exist(self): """Should not try to create category channel.""" - self.utils_mock.return_value = "foo" - await self.cog.createteam(self.cog, self.ctx, "bar", (MockMember() for _ in range(5))) - self.utils_mock.get.assert_called_once() + await self.cog.get_category(self.ctx) self.ctx.guild.create_category_channel.assert_not_awaited() async def test_channel_overwrites(self): -- cgit v1.2.3 From ea91aefe55bf52fca6714897347bb24d4a4efb5b Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 11 Jun 2020 11:41:25 +0300 Subject: Jam Tests: Apply recent changes to `test_category_dont_exist` --- tests/bot/cogs/test_jams.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index ae3e35dbb..ecd06179f 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -48,8 +48,7 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): async def test_category_dont_exist(self): """Should create code jam category.""" self.utils_mock.get.return_value = None - await self.cog.createteam(self.cog, self.ctx, "foo", (MockMember() for _ in range(5))) - self.utils_mock.get.assert_called_once() + await self.cog.get_category(self.ctx) self.ctx.guild.create_category_channel.assert_awaited_once() category_overwrites = self.ctx.guild.create_category_channel.call_args[1]["overwrites"] -- cgit v1.2.3 From 6e070a43f616f898e328bfc4581ed48551e73b12 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 11 Jun 2020 11:47:13 +0300 Subject: Jam Tests: Implement default arguments To avoid repeating same arguments, added default arguments that is unpacked on function call. --- tests/bot/cogs/test_jams.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index ecd06179f..94be8dd03 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -17,6 +17,7 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): self.ctx = MockContext(bot=self.bot, author=self.command_user, guild=self.guild) self.cog = CodeJams(self.bot) self.utils_mock = patch("bot.cogs.jams.utils").start() + self.default_args = [self.cog, self.ctx, "foo"] def tearDown(self): self.utils_mock.stop() @@ -30,9 +31,8 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): self.ctx.reset_mock() self.utils_mock.reset_mock() - await self.cog.createteam( - self.cog, self.ctx, team_name="foo", members=(MockMember() for _ in range(case)) - ) + await self.cog.createteam(*self.default_args, (MockMember() for _ in range(case))) + self.ctx.send.assert_awaited_once() self.cog.create_channels.assert_not_awaited() self.cog.add_roles.assert_not_awaited() @@ -41,7 +41,7 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): """Should `ctx.send` and exit early because duplicate members provided and total there is only 1 member.""" self.ctx.reset_mock() member = MockMember() - await self.cog.createteam(self.cog, self.ctx, "foo", (member for _ in range(5))) + await self.cog.createteam(*self.default_args, (member for _ in range(5))) self.ctx.send.assert_awaited_once() self.utils_mock.get.assert_not_called() -- cgit v1.2.3 From b129658bf260d458d5fad5925e945c78f881388a Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 11 Jun 2020 11:49:00 +0300 Subject: Jam Tests: Remove unnecessary `Context` mock resets --- tests/bot/cogs/test_jams.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index 94be8dd03..0f8ba3574 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -39,7 +39,6 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): async def test_duplicate_members_provided(self): """Should `ctx.send` and exit early because duplicate members provided and total there is only 1 member.""" - self.ctx.reset_mock() member = MockMember() await self.cog.createteam(*self.default_args, (member for _ in range(5))) self.ctx.send.assert_awaited_once() @@ -124,7 +123,6 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): async def test_result_sending(self): """Should call `ctx.send` when everything goes right.""" - self.ctx.reset_mock() members = [MockMember() for _ in range(5)] await self.cog.createteam(self.cog, self.ctx, "foo", members) self.ctx.send.assert_awaited_once() -- cgit v1.2.3 From 0481bcc1d99dd9d7fe9d41276599437b11670b27 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 11 Jun 2020 11:50:40 +0300 Subject: Jam Tests: Apply recent command splitting to `test_jam_roles_adding` --- tests/bot/cogs/test_jams.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index 0f8ba3574..54fe0b5f2 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -111,11 +111,11 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): """Should add team leader role to leader and jam role to every team member.""" leader_role = MockRole(name="Team Leader") jam_role = MockRole(name="Jammer") - self.ctx.guild.get_role.side_effect = [MockRole(), leader_role, jam_role] + self.ctx.guild.get_role.side_effect = [leader_role, jam_role] leader = MockMember() members = [leader] + [MockMember() for _ in range(4)] - await self.cog.createteam(self.cog, self.ctx, "foo", members) + await self.cog.add_roles(self.ctx, members) leader.add_roles.assert_any_await(leader_role) for member in members: -- cgit v1.2.3 From 8f0bc2e34ba2d741b3a7c89ad0437299b649153d Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 11 Jun 2020 18:54:33 +0300 Subject: Mod Utils: Add missing import what was removed Restore `textwrap` import that was removed with merge. --- bot/cogs/moderation/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 44dca7c9f..2acaf37f9 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -1,4 +1,5 @@ import logging +import textwrap import typing as t from datetime import datetime -- cgit v1.2.3 From f4767769afc8c6dfe4ac81d4e9b9e02f2f58054c Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 11 Jun 2020 18:06:59 +0200 Subject: Incidents: add #incidents-archive channel constant --- bot/constants.py | 1 + config-default.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index 02b82cf23..02c8adf43 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -401,6 +401,7 @@ class Channels(metaclass=YAMLGetter): helpers: int how_to_get_help: int incidents: int + incidents_archive: int message_log: int meta: int mod_alerts: int diff --git a/config-default.yml b/config-default.yml index c59abdc39..a68647f72 100644 --- a/config-default.yml +++ b/config-default.yml @@ -176,6 +176,7 @@ guild: organisation: &ORGANISATION 551789653284356126 staff_lounge: &STAFF_LOUNGE 464905259261755392 incidents: 714214212200562749 + incidents_archive: 720668923636351037 # Voice admins_voice: &ADMINS_VOICE 500734494840717332 -- cgit v1.2.3 From 5db3a82de9f37d769ed8983c83063dfdd6878fee Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 11 Jun 2020 18:26:21 +0200 Subject: Incidents: add #incidents-archive webhook constant --- bot/constants.py | 1 + config-default.yml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/constants.py b/bot/constants.py index 02c8adf43..c663db333 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -430,6 +430,7 @@ class Webhooks(metaclass=YAMLGetter): reddit: int duck_pond: int dev_log: int + incidents_archive: int class Roles(metaclass=YAMLGetter): diff --git a/config-default.yml b/config-default.yml index a68647f72..974ce508d 100644 --- a/config-default.yml +++ b/config-default.yml @@ -255,7 +255,7 @@ guild: duck_pond: 637821475327311927 dev_log: 680501655111729222 python_news: &PYNEWS_WEBHOOK 704381182279942324 - + incidents_archive: 720671599790915702 filter: -- cgit v1.2.3 From d520203717b8aaa6358071978a1ac9a23418d1c9 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 11 Jun 2020 18:45:50 +0200 Subject: Incidents: define allowed roles and emoji These serve as whitelists, i.e. any reaction using an emoji not explicitly allowed, or from a user not specifically allowed, will be rejected. Such reactions will be removed by the bot. --- bot/cogs/moderation/incidents.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index baceddf0c..49180da7c 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -4,7 +4,7 @@ from enum import Enum from discord.ext.commands import Cog from bot.bot import Bot -from bot.constants import Emojis +from bot.constants import Emojis, Roles log = logging.getLogger(__name__) @@ -17,6 +17,10 @@ class Signal(Enum): INVESTIGATING = Emojis.incident_investigating +ALLOWED_ROLES: t.Set[int] = {Roles.moderators, Roles.admins, Roles.owners} +ALLOWED_EMOJI: t.Set[str] = {signal.value for signal in Signal} + + class Incidents(Cog): """Automation for the #incidents channel.""" -- cgit v1.2.3 From 3195d16cf16f80dca6b66b87bc7b954d10d60e7a Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 13:25:02 +0200 Subject: Incidents: define method stubs for message event handling --- bot/cogs/moderation/incidents.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 49180da7c..c85a68a14 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -1,6 +1,7 @@ import logging from enum import Enum +import discord from discord.ext.commands import Cog from bot.bot import Bot @@ -26,3 +27,12 @@ class Incidents(Cog): def __init__(self, bot: Bot) -> None: self.bot = bot + + async def add_signals(self, incident: discord.Message) -> None: + """Add `Signal` member emoji to `incident` as reactions.""" + ... + + @Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """Pass each incident sent in #incidents to `add_signals`.""" + ... -- cgit v1.2.3 From 781d8f8d4bc76cb2ca9db4f3b7149d11892e714b Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 13:28:32 +0200 Subject: Incidents: implement `add_signals` helper Looks like it can be static, at least for now. --- bot/cogs/moderation/incidents.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index c85a68a14..2424c008d 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -1,4 +1,5 @@ import logging +import typing as t from enum import Enum import discord @@ -28,9 +29,12 @@ class Incidents(Cog): def __init__(self, bot: Bot) -> None: self.bot = bot - async def add_signals(self, incident: discord.Message) -> None: + @staticmethod + async def add_signals(incident: discord.Message) -> None: """Add `Signal` member emoji to `incident` as reactions.""" - ... + for signal_emoji in Signal: + log.debug(f"Adding reaction: {signal_emoji.value}") + await incident.add_reaction(signal_emoji.value) @Cog.listener() async def on_message(self, message: discord.Message) -> None: -- cgit v1.2.3 From e8bb1aa59dece803a920efb5ebbdd6098025bdc6 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 13:34:34 +0200 Subject: Incidents: implement `on_message` listener & guards --- bot/cogs/moderation/incidents.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 2424c008d..91b949173 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -6,7 +6,7 @@ import discord from discord.ext.commands import Cog from bot.bot import Bot -from bot.constants import Emojis, Roles +from bot.constants import Channels, Emojis, Roles log = logging.getLogger(__name__) @@ -38,5 +38,22 @@ class Incidents(Cog): @Cog.listener() async def on_message(self, message: discord.Message) -> None: - """Pass each incident sent in #incidents to `add_signals`.""" - ... + """ + Pass each incident sent in #incidents to `add_signals`. + + We recognize several exceptions. The following will be ignored: + * Messages sent outside of #incidents + * Messages Sent by bots + * Messages starting with the hash symbol # + + Prefix message with # in situations where a verbal response is necessary. + Each such message must be deleted manually. + """ + if message.channel.id != Channels.incidents or message.author.bot: + return + + if message.content.startswith("#"): + log.debug(f"Ignoring comment message: {message.content=}") + return + + await self.add_signals(message) -- cgit v1.2.3 From 5b6b2de2fdfb9b2893b4e9321e4a46b19b4bfb20 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 14:01:47 +0200 Subject: Incidents: implement & schedule `crawl_incidents` task See docstring for further information. This will run on start-up to retroactively add missing emoji. Ratelimit-wise this should be fine, as there should never be too many missing emoji. --- bot/cogs/moderation/incidents.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 91b949173..e773636e7 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -1,3 +1,4 @@ +import asyncio import logging import typing as t from enum import Enum @@ -27,7 +28,38 @@ class Incidents(Cog): """Automation for the #incidents channel.""" def __init__(self, bot: Bot) -> None: + """Schedule `crawl_task` on start-up.""" self.bot = bot + self.crawl_task = self.bot.loop.create_task(self.crawl_incidents()) + + async def crawl_incidents(self) -> None: + """ + Crawl #incidents and add missing emoji where necessary. + + This is to catch-up should an incident be reported while the bot wasn't listening. + Internally, we simply walk the channel history and pass each message to `on_message`. + + In order to avoid drowning in ratelimits, we take breaks after each message. + + Once this task is scheduled, listeners should await it. The crawl assumes that + the channel history doesn't change as we go over it. + """ + await self.bot.wait_until_guild_available() + incidents: discord.TextChannel = self.bot.get_channel(Channels.incidents) + + # Limit the query at 50 as in practice, there should never be this many messages, + # and if there are, something has likely gone very wrong + limit = 50 + + # Seconds to sleep after each message + sleep = 2 + + log.debug(f"Crawling messages in #incidents: {limit=}, {sleep=}") + async for message in incidents.history(limit=limit): + await self.on_message(message) + await asyncio.sleep(sleep) + + log.debug("Crawl task finished!") @staticmethod async def add_signals(incident: discord.Message) -> None: -- cgit v1.2.3 From 1aaaee1144f660af7a69d12f814d0073451da7be Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 14:02:58 +0200 Subject: Incidents: make `on_message` ignore pinned messages This is now necessary as we call the listener ourselves from the crawl task. An already existing, pinned message, can be received. --- bot/cogs/moderation/incidents.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index e773636e7..1b9d26522 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -77,6 +77,7 @@ class Incidents(Cog): * Messages sent outside of #incidents * Messages Sent by bots * Messages starting with the hash symbol # + * Pinned (header) messages Prefix message with # in situations where a verbal response is necessary. Each such message must be deleted manually. @@ -88,4 +89,8 @@ class Incidents(Cog): log.debug(f"Ignoring comment message: {message.content=}") return + if message.pinned: + log.debug(f"Ignoring header message: {message.pinned=}") + return + await self.add_signals(message) -- cgit v1.2.3 From 1f6cd4313c91ed114a1de04de14355648fe88bf9 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 14:06:12 +0200 Subject: Incidents: only `add_signals` if missing --- bot/cogs/moderation/incidents.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 1b9d26522..43b1106ad 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -64,9 +64,17 @@ class Incidents(Cog): @staticmethod async def add_signals(incident: discord.Message) -> None: """Add `Signal` member emoji to `incident` as reactions.""" + existing_reacts = {str(reaction.emoji) for reaction in incident.reactions if reaction.me} + for signal_emoji in Signal: - log.debug(f"Adding reaction: {signal_emoji.value}") - await incident.add_reaction(signal_emoji.value) + + # This will not raise, but it is a superfluous API call that can be avoided + if signal_emoji.value in existing_reacts: + log.debug(f"Skipping emoji as it's already been placed: {signal_emoji}") + + else: + log.debug(f"Adding reaction: {signal_emoji}") + await incident.add_reaction(signal_emoji.value) @Cog.listener() async def on_message(self, message: discord.Message) -> None: -- cgit v1.2.3 From 0f9f25e703325bae172148bb6a30c1118b905fcb Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 14:22:02 +0200 Subject: Incidents: add `event_lock` for simple event synchronization --- bot/cogs/moderation/incidents.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 43b1106ad..1cfa45dc4 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -28,8 +28,10 @@ class Incidents(Cog): """Automation for the #incidents channel.""" def __init__(self, bot: Bot) -> None: - """Schedule `crawl_task` on start-up.""" + """Prepare `event_lock` and schedule `crawl_task` on start-up.""" self.bot = bot + + self.event_lock = asyncio.Lock() self.crawl_task = self.bot.loop.create_task(self.crawl_incidents()) async def crawl_incidents(self) -> None: -- cgit v1.2.3 From 5762e57696978843991058f7bbfa826e3020dbba Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 14:53:00 +0200 Subject: Incidents: abstract incident checking into a helper method The code is now basically self-documenting, the docstring is no longer necessary. The ultimate goal is to allow `crawl_incidents` to be more smart about which messages need to be passed to `add_signals`, so that it doesn't need to sleep after each message. --- bot/cogs/moderation/incidents.py | 38 ++++++++++++++------------------------ 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 1cfa45dc4..e3c3922a1 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -24,6 +24,17 @@ ALLOWED_ROLES: t.Set[int] = {Roles.moderators, Roles.admins, Roles.owners} ALLOWED_EMOJI: t.Set[str] = {signal.value for signal in Signal} +def is_incident(message: discord.Message) -> bool: + """True if `message` qualifies as an incident, False otherwise.""" + conditions = ( + message.channel.id == Channels.incidents, # Message sent in #incidents + not message.author.bot, # Not by a bot + not message.content.startswith("#"), # Doesn't start with a hash + not message.pinned, # And isn't header + ) + return all(conditions) + + class Incidents(Cog): """Automation for the #incidents channel.""" @@ -80,27 +91,6 @@ class Incidents(Cog): @Cog.listener() async def on_message(self, message: discord.Message) -> None: - """ - Pass each incident sent in #incidents to `add_signals`. - - We recognize several exceptions. The following will be ignored: - * Messages sent outside of #incidents - * Messages Sent by bots - * Messages starting with the hash symbol # - * Pinned (header) messages - - Prefix message with # in situations where a verbal response is necessary. - Each such message must be deleted manually. - """ - if message.channel.id != Channels.incidents or message.author.bot: - return - - if message.content.startswith("#"): - log.debug(f"Ignoring comment message: {message.content=}") - return - - if message.pinned: - log.debug(f"Ignoring header message: {message.pinned=}") - return - - await self.add_signals(message) + """Pass `message` to `add_signals` if and only if it satisfies `is_incident`.""" + if is_incident(message): + await self.add_signals(message) -- cgit v1.2.3 From 166fc5f441a56d86202e857059011fdc75ce2740 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 15:04:24 +0200 Subject: Incidents: implement `own_reactions` helper --- bot/cogs/moderation/incidents.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index e3c3922a1..8a49ec8b1 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -35,6 +35,11 @@ def is_incident(message: discord.Message) -> bool: return all(conditions) +def own_reactions(message: discord.Message) -> t.Set[str]: + """Get the set of reactions placed on `message` by the bot itself.""" + return {str(reaction.emoji) for reaction in message.reactions if reaction.me} + + class Incidents(Cog): """Automation for the #incidents channel.""" @@ -77,7 +82,7 @@ class Incidents(Cog): @staticmethod async def add_signals(incident: discord.Message) -> None: """Add `Signal` member emoji to `incident` as reactions.""" - existing_reacts = {str(reaction.emoji) for reaction in incident.reactions if reaction.me} + existing_reacts = own_reactions(incident) for signal_emoji in Signal: -- cgit v1.2.3 From f7756b0246dec293f9918f3ea3ac4d6139affddd Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 15:05:48 +0200 Subject: Incidents: implement `has_signals` helper --- bot/cogs/moderation/incidents.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 8a49ec8b1..0d146bdc5 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -40,6 +40,12 @@ def own_reactions(message: discord.Message) -> t.Set[str]: return {str(reaction.emoji) for reaction in message.reactions if reaction.me} +def has_signals(message: discord.Message) -> bool: + """True if `message` already has all `Signal` reactions, False otherwise.""" + missing_signals = ALLOWED_EMOJI - own_reactions(message) + return not missing_signals + + class Incidents(Cog): """Automation for the #incidents channel.""" -- cgit v1.2.3 From b7f61a4bf92b19a42dd1f72336d67a092b5d8029 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 15:10:21 +0200 Subject: Incidents: move `add_signals` to module namespace Looks like we'll need quite a few helpers, and I think it's cleaner to keep them at module level. It helps avoid the question of: what do I do if a staticmethod depends on another staticmethod? --- bot/cogs/moderation/incidents.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 0d146bdc5..f7ef86836 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -46,6 +46,21 @@ def has_signals(message: discord.Message) -> bool: return not missing_signals +async def add_signals(incident: discord.Message) -> None: + """Add `Signal` member emoji to `incident` as reactions.""" + existing_reacts = own_reactions(incident) + + for signal_emoji in Signal: + + # This will not raise, but it is a superfluous API call that can be avoided + if signal_emoji.value in existing_reacts: + log.debug(f"Skipping emoji as it's already been placed: {signal_emoji}") + + else: + log.debug(f"Adding reaction: {signal_emoji}") + await incident.add_reaction(signal_emoji.value) + + class Incidents(Cog): """Automation for the #incidents channel.""" @@ -85,23 +100,8 @@ class Incidents(Cog): log.debug("Crawl task finished!") - @staticmethod - async def add_signals(incident: discord.Message) -> None: - """Add `Signal` member emoji to `incident` as reactions.""" - existing_reacts = own_reactions(incident) - - for signal_emoji in Signal: - - # This will not raise, but it is a superfluous API call that can be avoided - if signal_emoji.value in existing_reacts: - log.debug(f"Skipping emoji as it's already been placed: {signal_emoji}") - - else: - log.debug(f"Adding reaction: {signal_emoji}") - await incident.add_reaction(signal_emoji.value) - @Cog.listener() async def on_message(self, message: discord.Message) -> None: """Pass `message` to `add_signals` if and only if it satisfies `is_incident`.""" if is_incident(message): - await self.add_signals(message) + await add_signals(message) -- cgit v1.2.3 From 9a540a344ad79cd5766389d36e75536d751862b0 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 15:19:15 +0200 Subject: Incidents: make `crawl_incidents` smarter The crawler now avoids making API calls for messages which: * Are not incidents * Already have all signals As a result, we can sleep only after making actual calls. This speeds up the task completion considerable, while also making it lighter on the API. Victory! --- bot/cogs/moderation/incidents.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index f7ef86836..d2b4581e7 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -76,12 +76,10 @@ class Incidents(Cog): Crawl #incidents and add missing emoji where necessary. This is to catch-up should an incident be reported while the bot wasn't listening. - Internally, we simply walk the channel history and pass each message to `on_message`. + After adding reactions, we take a short break to avoid drowning in ratelimits. - In order to avoid drowning in ratelimits, we take breaks after each message. - - Once this task is scheduled, listeners should await it. The crawl assumes that - the channel history doesn't change as we go over it. + Once this task is scheduled, listeners that change messages should await it. + The crawl assumes that the channel history doesn't change as we go over it. """ await self.bot.wait_until_guild_available() incidents: discord.TextChannel = self.bot.get_channel(Channels.incidents) @@ -95,7 +93,16 @@ class Incidents(Cog): log.debug(f"Crawling messages in #incidents: {limit=}, {sleep=}") async for message in incidents.history(limit=limit): - await self.on_message(message) + + if not is_incident(message): + log.debug("Skipping message: not an incident") + continue + + if has_signals(message): + log.debug("Skipping message: already has all signals") + continue + + await add_signals(message) await asyncio.sleep(sleep) log.debug("Crawl task finished!") -- cgit v1.2.3 From 6c58ecb647b046c6a9a1e2b6d9b4d0e0f326e9bd Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 12 Jun 2020 16:54:55 +0300 Subject: Remove deprecated avatar hash in `test_post_user` --- tests/bot/cogs/moderation/test_utils.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 248adbcb8..596f077b5 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -203,14 +203,13 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): async def test_post_user(self): """Should POST a new user and return the response if successful or otherwise send an error message.""" - user = MockUser(avatar="abc", discriminator=5678, id=1234, name="Test user") + user = MockUser(discriminator=5678, id=1234, name="Test user") test_cases = [ { "user": user, "post_result": "bar", "raise_error": None, "payload": { - "avatar_hash": "abc", "discriminator": 5678, "id": self.user.id, "in_guild": False, @@ -223,7 +222,6 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "post_result": "foo", "raise_error": ResponseCodeError(MagicMock(status=400), "foo"), "payload": { - "avatar_hash": 0, "discriminator": 0, "id": self.member.id, "in_guild": False, -- cgit v1.2.3 From 9e6835cef2210910db4ad110c0906a09fd5c5411 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 15:40:39 +0200 Subject: Incidents: implement `resolve_message` See docstring. The exception log is DEBUG level as failure does not necessarily indicate that we have done something wrong. We rely on the API to tell us that the message no longer exists in situations where we have 2 coroutines racing to archive the same message. --- bot/cogs/moderation/incidents.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index d2b4581e7..d994054c8 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -107,6 +107,37 @@ class Incidents(Cog): log.debug("Crawl task finished!") + async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]: + """ + Get `discord.Message` for `message_id` from cache, or API. + + We first look into the local cache to see if the message is present. + + If not, we try to fetch the message from the API. This is necessary for messages + which were sent before the bot's current session. + + However, in an edge-case, it is also possible that the message was already deleted, + and the API will return a 404. In such a case, None will be returned. This signals + that the event for `message_id` should be ignored. + """ + await self.bot.wait_until_guild_available() # First make sure that the cache is ready + log.debug(f"Resolving message for: {message_id=}") + message: discord.Message = self.bot._connection._get_message(message_id) # noqa: Private attribute + + if message is not None: + log.debug("Message was found in cache") + return message + + log.debug("Message not found, attempting to fetch") + try: + message = await self.bot.get_channel(Channels.incidents).fetch_message(message_id) + except Exception as exc: + log.debug(f"Failed to fetch message: {exc}") + return None + else: + log.debug("Message fetched successfully!") + return message + @Cog.listener() async def on_message(self, message: discord.Message) -> None: """Pass `message` to `add_signals` if and only if it satisfies `is_incident`.""" -- cgit v1.2.3 From 3c699936bf3cfa076f7791d6b8fe16ad4dd94aa6 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 15:59:15 +0200 Subject: Incidents: implement reaction listener See docstring! --- bot/cogs/moderation/incidents.py | 46 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index d994054c8..88ed04f45 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -138,6 +138,52 @@ class Incidents(Cog): log.debug("Message fetched successfully!") return message + async def process_event(self, reaction: str, message: discord.Message, member: discord.Member) -> None: + log.debug("Processing event...") + + @Cog.listener() + async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent) -> None: + """ + Pre-process `payload` and pass it to `process_event` if appropriate. + + We abort instantly if `payload` doesn't relate to a message sent in #incidents. + + If `payload` relates to a message in #incidents, we first ensure that `crawl_task` has + finished, to make sure we don't mutate channel state as we're crawling it. + + Next, we acquire `event_lock` - to prevent racing, events are processed one at a time. + + Once we have the lock, the `discord.Message` object for this event must be resolved. + If the lock was previously held by an event which successfully relayed the incident, + this will fail and we abort the current event. + + Finally, with both the lock and the `discord.Message` instance in our hands, we delegate + to `process_event` to handle the event. + + The justification for using a raw listener is the need to receive events for messages + which were not cached in the current session. As a result, a certain amount of + complexity is introduced, but at the moment this doesn't appear to be avoidable. + """ + if payload.channel_id != Channels.incidents: + return + + log.debug(f"Received reaction add event in #incidents, waiting for crawler: {self.crawl_task.done()=}") + await self.crawl_task + + log.debug(f"Acquiring event lock: {self.event_lock.locked()=}") + async with self.event_lock: + message = await self.resolve_message(payload.message_id) + + if message is None: + log.debug("Listener will abort as related message does not exist!") + return + + if not is_incident(message): + log.debug("Ignoring event for a non-incident message") + return + + await self.process_event(str(payload.emoji), message, payload.member) + @Cog.listener() async def on_message(self, message: discord.Message) -> None: """Pass `message` to `add_signals` if and only if it satisfies `is_incident`.""" -- cgit v1.2.3 From d7165bf5547340242cb99460a35cabd753d60c42 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 17:17:36 +0200 Subject: Incidents: implement `archive` method --- bot/cogs/moderation/incidents.py | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 88ed04f45..8781d6749 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -7,7 +7,7 @@ import discord from discord.ext.commands import Cog from bot.bot import Bot -from bot.constants import Channels, Emojis, Roles +from bot.constants import Channels, Emojis, Roles, Webhooks log = logging.getLogger(__name__) @@ -107,6 +107,44 @@ class Incidents(Cog): log.debug("Crawl task finished!") + async def archive(self, incident: discord.Message, outcome: Signal) -> bool: + """ + Relay `incident` to the #incidents-archive channel. + + The following pieces of information are relayed: + * Incident message content (clean, pingless) + * Incident author name (as webhook author) + * Incident author avatar (as webhook avatar) + * Resolution signal (`outcome`) + + Return True if the relay finishes successfully. If anything goes wrong, meaning + not all information was relayed, return False. This signals that the original + message is not safe to be deleted, as we will lose some information. + """ + log.debug(f"Archiving incident: {incident.id} with outcome: {outcome}") + try: + # First we try to grab the webhook + webhook: discord.Webhook = await self.bot.fetch_webhook(Webhooks.incidents_archive) + + # Now relay the incident + message: discord.Message = await webhook.send( + content=incident.clean_content, # Clean content will prevent mentions from pinging + username=incident.author.name, + avatar_url=incident.author.avatar_url, + wait=True, # This makes the method return the sent Message object + ) + + # Finally add the `outcome` emoji + await message.add_reaction(outcome.value) + + except Exception as exc: + log.exception("Failed to archive incident to #incidents-archive", exc_info=exc) + return False + + else: + log.debug("Message archived successfully!") + return True + async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]: """ Get `discord.Message` for `message_id` from cache, or API. -- cgit v1.2.3 From 44e30289d9682a92ef6e6d2ca8e9cf9b669ad65c Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 17:23:26 +0200 Subject: Incidents: implement `make_confirmation_task` method --- bot/cogs/moderation/incidents.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 8781d6749..2186530d9 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -145,6 +145,21 @@ class Incidents(Cog): log.debug("Message archived successfully!") return True + def make_confirmation_task(self, incident: discord.Message, timeout: int = 5) -> asyncio.Task: + """ + Create a task to wait `timeout` seconds for `incident` to be deleted. + + If `timeout` passes, this will raise `asyncio.TimeoutError`, signaling that we haven't + been able to confirm that the message was deleted. + """ + log.debug(f"Confirmation task will wait {timeout=} seconds for {incident.id=} to be deleted") + coroutine = self.bot.wait_for( + event="raw_message_delete", + check=lambda payload: payload.message_id == incident.id, + timeout=timeout, + ) + return self.bot.loop.create_task(coroutine) + async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]: """ Get `discord.Message` for `message_id` from cache, or API. -- cgit v1.2.3 From 7bc6aff14c5a78b708b11cafbd4eba431b3fe52b Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 17:28:56 +0200 Subject: Incidents: implement `process_event` coroutine This contains the main logic for handling reactions and glues all the helpers together. Unfortunately, gracefully handling everything that can go wrong in the process requires quite a lot of code ~ but, at least to me, it seems like this all should now be fairly safe. The idea to await the message delete event before releasing the lock was conceived by Ves, while Mark helped me refine it. Co-authored-by: Sebastiaan Zeeff Co-authored-by: MarkKoz --- bot/cogs/moderation/incidents.py | 55 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 2186530d9..00cceca7d 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -160,6 +160,58 @@ class Incidents(Cog): ) return self.bot.loop.create_task(coroutine) + async def process_event(self, reaction: str, incident: discord.Message, member: discord.Member) -> None: + """ + Process a valid `reaction_add` event in #incidents. + + First, we check that the reaction is a recognized `Signal` member, and that it was sent by + a permitted user (at least one role in `ALLOWED_ROLES`). If not, the reaction is removed. + + If the reaction was either `Signal.ACTIONED` or `Signal.NOT_ACTIONED`, we attempt to relay + the report to #incidents-archive. If successful, the original message is deleted. + + We do not release `event_lock` until we receive the corresponding `message_delete` event. + This ensures that if there is a racing event awaiting the lock, it will fail to find the + message, and will abort. + """ + members_roles: t.Set[int] = {role.id for role in member.roles} + if not members_roles & ALLOWED_ROLES: # Intersection is truthy on at least 1 common element + log.debug(f"Removing invalid reaction: user {member} is not permitted to send signals") + await incident.remove_reaction(reaction, member) + return + + if reaction not in ALLOWED_EMOJI: + log.debug(f"Removing invalid reaction: emoji {reaction} is not a valid signal") + await incident.remove_reaction(reaction, member) + return + + # If we reach this point, we know that `emoji` is a `Signal` member + signal = Signal(reaction) + log.debug(f"Received signal: {signal}") + + if signal not in (Signal.ACTIONED, Signal.NOT_ACTIONED): + log.debug("Reaction was valid, but no action is currently defined for it") + return + + relay_successful = await self.archive(incident, signal) + if not relay_successful: + log.debug("Original message will not be deleted as we failed to relay it to the archive") + return + + timeout = 5 # Seconds + confirmation_task = self.make_confirmation_task(incident, timeout) + + log.debug("Deleting original message") + await incident.delete() + + log.debug(f"Awaiting deletion confirmation: {timeout=} seconds") + try: + await confirmation_task + except asyncio.TimeoutError: + log.warning(f"Did not receive incident deletion confirmation within {timeout} seconds!") + else: + log.debug("Deletion was confirmed") + async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]: """ Get `discord.Message` for `message_id` from cache, or API. @@ -191,9 +243,6 @@ class Incidents(Cog): log.debug("Message fetched successfully!") return message - async def process_event(self, reaction: str, message: discord.Message, member: discord.Member) -> None: - log.debug("Processing event...") - @Cog.listener() async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent) -> None: """ -- cgit v1.2.3 From 506f91a77d3dd1bb92222bd3fce4a7316677ddbb Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 17:37:27 +0200 Subject: Incidents: do not process reaction events from bots --- bot/cogs/moderation/incidents.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 00cceca7d..f19bdb41f 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -248,7 +248,8 @@ class Incidents(Cog): """ Pre-process `payload` and pass it to `process_event` if appropriate. - We abort instantly if `payload` doesn't relate to a message sent in #incidents. + We abort instantly if `payload` doesn't relate to a message sent in #incidents, + or if it was sent by a bot. If `payload` relates to a message in #incidents, we first ensure that `crawl_task` has finished, to make sure we don't mutate channel state as we're crawling it. @@ -266,7 +267,7 @@ class Incidents(Cog): which were not cached in the current session. As a result, a certain amount of complexity is introduced, but at the moment this doesn't appear to be avoidable. """ - if payload.channel_id != Channels.incidents: + if payload.channel_id != Channels.incidents or payload.member.bot: return log.debug(f"Received reaction add event in #incidents, waiting for crawler: {self.crawl_task.done()=}") -- cgit v1.2.3 From f41794d31135209a7a38cc9c17ac62d3e06f6279 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 17:56:05 +0200 Subject: Incidents: log `event_lock` release --- bot/cogs/moderation/incidents.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index f19bdb41f..d69439dc3 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -286,6 +286,7 @@ class Incidents(Cog): return await self.process_event(str(payload.emoji), message, payload.member) + log.debug("Releasing event lock") @Cog.listener() async def on_message(self, message: discord.Message) -> None: -- cgit v1.2.3 From 0eb62724baba42fffffcd47ff4fe5451dc521593 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 18:01:10 +0200 Subject: Incidents: avoid lambda check; make regular function --- bot/cogs/moderation/incidents.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index d69439dc3..16af17f99 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -153,11 +153,11 @@ class Incidents(Cog): been able to confirm that the message was deleted. """ log.debug(f"Confirmation task will wait {timeout=} seconds for {incident.id=} to be deleted") - coroutine = self.bot.wait_for( - event="raw_message_delete", - check=lambda payload: payload.message_id == incident.id, - timeout=timeout, - ) + + def check(payload: discord.RawReactionActionEvent) -> bool: + return payload.message_id == incident.id + + coroutine = self.bot.wait_for(event="raw_message_delete", check=check, timeout=timeout) return self.bot.loop.create_task(coroutine) async def process_event(self, reaction: str, incident: discord.Message, member: discord.Member) -> None: -- cgit v1.2.3 From 4cc6f759f53ebe31d5025ff902189ab211409d4f Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 12 Jun 2020 19:30:30 +0300 Subject: Implement description shortening to infraction notify tests --- tests/bot/cogs/moderation/test_utils.py | 35 +++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 596f077b5..363d8938a 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -1,3 +1,4 @@ +import textwrap import unittest from datetime import datetime from unittest.mock import AsyncMock, MagicMock, call, patch @@ -71,11 +72,11 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "args": (self.user, "ban", "2020-02-26 09:20 (23 hours and 59 minutes)"), "expected_output": Embed( title=utils.INFRACTION_TITLE, - description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( + description=textwrap.shorten(utils.INFRACTION_DESCRIPTION_TEMPLATE.format( type="Ban", expires="2020-02-26 09:20 (23 hours and 59 minutes)", reason="No reason provided." - ), + ), width=2048, placeholder="..."), colour=Colours.soft_red, url=utils.RULES_URL ).set_author( @@ -89,11 +90,11 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "args": (self.user, "warning", None, "Test reason."), "expected_output": Embed( title=utils.INFRACTION_TITLE, - description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( + description=textwrap.shorten(utils.INFRACTION_DESCRIPTION_TEMPLATE.format( type="Warning", expires="N/A", reason="Test reason." - ), + ), width=2048, placeholder="..."), colour=Colours.soft_red, url=utils.RULES_URL ).set_author( @@ -107,11 +108,11 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "args": (self.user, "note", None, None, Icons.defcon_denied), "expected_output": Embed( title=utils.INFRACTION_TITLE, - description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( + description=textwrap.shorten(utils.INFRACTION_DESCRIPTION_TEMPLATE.format( type="Note", expires="N/A", reason="No reason provided." - ), + ), width=2048, placeholder="..."), colour=Colours.soft_red, url=utils.RULES_URL ).set_author( @@ -125,11 +126,11 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "args": (self.user, "mute", "2020-02-26 09:20 (23 hours and 59 minutes)", "Test", Icons.defcon_denied), "expected_output": Embed( title=utils.INFRACTION_TITLE, - description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( + description=textwrap.shorten(utils.INFRACTION_DESCRIPTION_TEMPLATE.format( type="Mute", expires="2020-02-26 09:20 (23 hours and 59 minutes)", reason="Test" - ), + ), width=2048, placeholder="..."), colour=Colours.soft_red, url=utils.RULES_URL ).set_author( @@ -138,6 +139,24 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): icon_url=Icons.defcon_denied ).set_footer(text=utils.INFRACTION_APPEAL_FOOTER), "send_result": False + }, + { + "args": (self.user, "mute", None, "foo bar" * 4000, Icons.defcon_denied), + "expected_output": Embed( + title=utils.INFRACTION_TITLE, + description=textwrap.shorten(utils.INFRACTION_DESCRIPTION_TEMPLATE.format( + type="Mute", + expires="N/A", + reason="foo bar" * 4000 + ), width=2048, placeholder="..."), + colour=Colours.soft_red, + url=utils.RULES_URL + ).set_author( + name=utils.INFRACTION_AUTHOR_NAME, + url=utils.RULES_URL, + icon_url=Icons.defcon_denied + ).set_footer(text=utils.INFRACTION_APPEAL_FOOTER), + "send_result": True } ] -- cgit v1.2.3 From 0d0f4318dc2c08d87d473ecb2d66a5622d36cf9d Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 12 Jun 2020 19:53:00 +0300 Subject: Increase coverage of moderation utils tests --- tests/bot/cogs/moderation/test_utils.py | 41 +++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 363d8938a..77f926a48 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -30,12 +30,20 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): { "get_return_value": [], "expected_output": None, - "infraction_nr": None + "infraction_nr": None, + "send_msg": True }, { "get_return_value": [{"id": 123987}], "expected_output": {"id": 123987}, - "infraction_nr": "123987" + "infraction_nr": "123987", + "send_msg": False + }, + { + "get_return_value": [{"id": 123987}], + "expected_output": {"id": 123987}, + "infraction_nr": "123987", + "send_msg": True } ] @@ -52,13 +60,16 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): self.bot.api_client.get.return_value = case["get_return_value"] - result = await utils.get_active_infraction(self.ctx, self.member, "ban") + result = await utils.get_active_infraction(self.ctx, self.member, "ban", send_msg=case["send_msg"]) self.assertEqual(result, case["expected_output"]) self.bot.api_client.get.assert_awaited_once_with("bot/infractions", params=params) - if result: + if case["send_msg"] and case["get_return_value"]: + self.ctx.send.assert_awaited_once() self.assertTrue(case["infraction_nr"] in self.ctx.send.call_args[0][0]) self.assertTrue("ban" in self.ctx.send.call_args[0][0]) + else: + self.ctx.send.assert_not_awaited() @patch("bot.cogs.moderation.utils.send_private_embed") async def test_notify_infraction(self, send_private_embed_mock): @@ -220,9 +231,11 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): send_private_embed_mock.assert_awaited_once_with(args[0], embed) - async def test_post_user(self): + @patch("bot.cogs.moderation.utils.log") + async def test_post_user(self, log_mock): """Should POST a new user and return the response if successful or otherwise send an error message.""" user = MockUser(discriminator=5678, id=1234, name="Test user") + some_mock = MagicMock(discriminator=3333) test_cases = [ { "user": user, @@ -247,6 +260,18 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "name": "Name unknown", "roles": [] } + }, + { + "user": some_mock, + "post_result": "bar", + "raise_error": None, + "payload": { + "discriminator": some_mock.discriminator, + "id": some_mock.id, + "in_guild": False, + "name": some_mock.name, + "roles": [] + } } ] @@ -257,6 +282,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): payload = case["payload"] with self.subTest(user=test_user, result=expected, error=error, payload=payload): + log_mock.reset_mock() self.bot.api_client.post.reset_mock(side_effect=True) self.ctx.bot.api_client.post.return_value = expected @@ -275,6 +301,11 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): self.ctx.send.assert_awaited_once() self.assertTrue(str(error.status) in self.ctx.send.call_args[0][0]) + if isinstance(test_user, MagicMock): + log_mock.debug.assert_called_once() + else: + log_mock.debug.assert_not_called() + async def test_send_private_embed(self): """Should DM the user and return `True` on success or `False` on failure.""" embed = Embed(title="Test", description="Test val") -- cgit v1.2.3 From 39691c052d50907c049f3294cc5eef6536461656 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 21:08:14 +0200 Subject: Incidents: extend documentation This adds a proper class docstring & small touch-ups to local comments where necessary. --- bot/cogs/moderation/incidents.py | 60 ++++++++++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 16af17f99..151584d38 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -13,13 +13,19 @@ log = logging.getLogger(__name__) class Signal(Enum): - """Recognized incident status signals.""" + """ + Recognized incident status signals. + + This binds emoji to actions. The bot will only react to emoji linked here. + All other signals are seen as invalid. + """ ACTIONED = Emojis.incident_actioned NOT_ACTIONED = Emojis.incident_unactioned INVESTIGATING = Emojis.incident_investigating +# Reactions from roles not listed here, or using emoji not listed here, will be removed ALLOWED_ROLES: t.Set[int] = {Roles.moderators, Roles.admins, Roles.owners} ALLOWED_EMOJI: t.Set[str] = {signal.value for signal in Signal} @@ -42,12 +48,16 @@ def own_reactions(message: discord.Message) -> t.Set[str]: def has_signals(message: discord.Message) -> bool: """True if `message` already has all `Signal` reactions, False otherwise.""" - missing_signals = ALLOWED_EMOJI - own_reactions(message) + missing_signals = ALLOWED_EMOJI - own_reactions(message) # In `ALLOWED_EMOJI` but not in `own_reactions(message)` return not missing_signals async def add_signals(incident: discord.Message) -> None: - """Add `Signal` member emoji to `incident` as reactions.""" + """ + Add `Signal` member emoji to `incident` as reactions. + + If the emoji has already been placed on `incident` by the bot, it will be skipped. + """ existing_reacts = own_reactions(incident) for signal_emoji in Signal: @@ -62,7 +72,34 @@ async def add_signals(incident: discord.Message) -> None: class Incidents(Cog): - """Automation for the #incidents channel.""" + """ + Automation for the #incidents channel. + + This cog does not provide a command API, it only reacts to the following events. + + On start-up: + * Crawl #incidents and add missing `Signal` emoji where appropriate + * This is to retro-actively add the available options for messages which + were sent while the bot wasn't listening + * Pinned messages and message starting with # do not qualify as incidents + * See: `crawl_incidents` + + On message: + * Add `Signal` member emoji if message qualifies as an incident + * Ignore messages starting with # + * Use this if verbal communication is necessary + * Each such message must be deleted manually once appropriate + * See: `on_message` + + On reaction: + * Remove reaction if not permitted (`ALLOWED_EMOJI`, `ALLOWED_ROLES`) + * If `Signal.ACTIONED` or `Signal.NOT_ACTIONED` were chosen, attempt to + relay the incident message to #incidents-archive + * If relay successful, delete original message + * See: `on_raw_reaction_add` + + Please refer to function docstrings for implementation details. + """ def __init__(self, bot: Bot) -> None: """Prepare `event_lock` and schedule `crawl_task` on start-up.""" @@ -76,7 +113,7 @@ class Incidents(Cog): Crawl #incidents and add missing emoji where necessary. This is to catch-up should an incident be reported while the bot wasn't listening. - After adding reactions, we take a short break to avoid drowning in ratelimits. + After adding each reaction, we take a short break to avoid drowning in ratelimits. Once this task is scheduled, listeners that change messages should await it. The crawl assumes that the channel history doesn't change as we go over it. @@ -88,7 +125,7 @@ class Incidents(Cog): # and if there are, something has likely gone very wrong limit = 50 - # Seconds to sleep after each message + # Seconds to sleep after adding reactions to a message sleep = 2 log.debug(f"Crawling messages in #incidents: {limit=}, {sleep=}") @@ -162,7 +199,7 @@ class Incidents(Cog): async def process_event(self, reaction: str, incident: discord.Message, member: discord.Member) -> None: """ - Process a valid `reaction_add` event in #incidents. + Process a `reaction_add` event in #incidents. First, we check that the reaction is a recognized `Signal` member, and that it was sent by a permitted user (at least one role in `ALLOWED_ROLES`). If not, the reaction is removed. @@ -172,7 +209,8 @@ class Incidents(Cog): We do not release `event_lock` until we receive the corresponding `message_delete` event. This ensures that if there is a racing event awaiting the lock, it will fail to find the - message, and will abort. + message, and will abort. There is a `timeout` to ensure that this doesn't hold the lock + forever should something go wrong. """ members_roles: t.Set[int] = {role.id for role in member.roles} if not members_roles & ALLOWED_ROLES: # Intersection is truthy on at least 1 common element @@ -221,9 +259,9 @@ class Incidents(Cog): If not, we try to fetch the message from the API. This is necessary for messages which were sent before the bot's current session. - However, in an edge-case, it is also possible that the message was already deleted, - and the API will return a 404. In such a case, None will be returned. This signals - that the event for `message_id` should be ignored. + In an edge-case, it is also possible that the message was already deleted, and + the API will respond with a 404. In such a case, None will be returned. + This signals that the event for `message_id` should be ignored. """ await self.bot.wait_until_guild_available() # First make sure that the cache is ready log.debug(f"Resolving message for: {message_id=}") -- cgit v1.2.3 From 5c70a7dad3ee59e865df08affe7905a843a823ce Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 22:05:15 +0200 Subject: Incidents tests: create new test module --- tests/bot/cogs/moderation/test_incidents.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/bot/cogs/moderation/test_incidents.py diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py new file mode 100644 index 000000000..e69de29bb -- cgit v1.2.3 From ae5028d5966ba126f902783db8ad685646f45f37 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 23:14:41 +0200 Subject: Incidents tests: write tests for module-level helpers --- tests/bot/cogs/moderation/test_incidents.py | 135 ++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index e69de29bb..4c1f9bc07 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -0,0 +1,135 @@ +import enum +import unittest +from unittest.mock import AsyncMock, MagicMock, call, patch + +import discord + +from bot.cogs.moderation import incidents + + +@patch("bot.constants.Channels.incidents", 123) +class TestIsIncident(unittest.TestCase): + """ + Collection of tests for the `is_incident` helper function. + + In `setUp`, we will create a mock message which should qualify as an incident. Each + test case will then mutate this instance to make it **not** qualify, in various ways. + + Notice that we patch the #incidents channel id globally for this class. + """ + + def setUp(self) -> None: + """Prepare a mock message which should qualify as an incident.""" + self.incident = MagicMock( + discord.Message, + channel=MagicMock(discord.TextChannel, id=123), + content="this is an incident", + author=MagicMock(discord.User, bot=False), + pinned=False, + ) + + def test_is_incident_true(self): + """Message qualifies as an incident if unchanged.""" + self.assertTrue(incidents.is_incident(self.incident)) + + def check_false(self): + """Assert that `self.incident` does **not** qualify as an incident.""" + self.assertFalse(incidents.is_incident(self.incident)) + + def test_is_incident_false_channel(self): + """Message doesn't qualify if sent outside of #incidents.""" + self.incident.channel = MagicMock(discord.TextChannel, id=456) + self.check_false() + + def test_is_incident_false_content(self): + """Message doesn't qualify if content begins with hash symbol.""" + self.incident.content = "# this is a comment message" + self.check_false() + + def test_is_incident_false_author(self): + """Message doesn't qualify if author is a bot.""" + self.incident.author = MagicMock(discord.User, bot=True) + self.check_false() + + def test_is_incident_false_pinned(self): + """Message doesn't qualify if it is pinned.""" + self.incident.pinned = True + self.check_false() + + +class TestOwnReactions(unittest.TestCase): + """Assertions for the `own_reactions` function.""" + + def test_own_reactions(self): + """Only bot's own emoji are extracted from the input incident.""" + reactions = ( + MagicMock(discord.Reaction, emoji="A", me=True), + MagicMock(discord.Reaction, emoji="B", me=True), + MagicMock(discord.Reaction, emoji="C", me=False), + ) + message = MagicMock(discord.Message, reactions=reactions) + self.assertSetEqual(incidents.own_reactions(message), {"A", "B"}) + + +@patch("bot.cogs.moderation.incidents.ALLOWED_EMOJI", {"A", "B"}) +class TestHasSignals(unittest.TestCase): + """ + Assertions for the `has_signals` function. + + We patch `ALLOWED_EMOJI` globally. Each test function then patches `own_reactions` + as appropriate. + """ + + def test_has_signals_true(self): + """True when `own_reactions` returns all emoji in `ALLOWED_EMOJI`.""" + message = MagicMock(discord.Message) + own_reactions = MagicMock(return_value={"A", "B"}) + + with patch("bot.cogs.moderation.incidents.own_reactions", own_reactions): + self.assertTrue(incidents.has_signals(message)) + + def test_has_signals_false(self): + """False when `own_reactions` does not return all emoji in `ALLOWED_EMOJI`.""" + message = MagicMock(discord.Message) + own_reactions = MagicMock(return_value={"A", "C"}) + + with patch("bot.cogs.moderation.incidents.own_reactions", own_reactions): + self.assertFalse(incidents.has_signals(message)) + + +class Signal(enum.Enum): + A = "A" + B = "B" + + +@patch("bot.cogs.moderation.incidents.Signal", Signal) +class TestAddSignals(unittest.IsolatedAsyncioTestCase): + """ + Assertions for the `add_signals` coroutine. + + These are all fairly similar and could go into a single test function, but I found the + patching & sub-testing fairly awkward in that case and decided to split them up + to avoid unnecessary syntax noise. + """ + + def setUp(self): + """Prepare a mock incident message for tests to use.""" + self.incident = MagicMock(discord.Message, add_reaction=AsyncMock()) + + @patch("bot.cogs.moderation.incidents.own_reactions", MagicMock(return_value=set())) + async def test_add_signals_missing(self): + """All emoji are added when none are present.""" + await incidents.add_signals(self.incident) + self.incident.add_reaction.assert_has_calls([call("A"), call("B")]) + + @patch("bot.cogs.moderation.incidents.own_reactions", MagicMock(return_value={"A"})) + async def test_add_signals_partial(self): + """Only missing emoji are added when some are present.""" + await incidents.add_signals(self.incident) + self.incident.add_reaction.assert_has_calls([call("B")]) + + @patch("bot.cogs.moderation.incidents.own_reactions", MagicMock(return_value={"A", "B"})) + async def test_add_signals_present(self): + """No emoji are added when all are present.""" + await incidents.add_signals(self.incident) + self.incident.add_reaction.assert_not_called() -- cgit v1.2.3 From 314f9a829a6bc12677bac17ff04b2501b4d93f0c Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 13 Jun 2020 08:08:36 +0300 Subject: Fix `create_channels`, `get_category` docstrings --- bot/cogs/jams.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py index 16dda35c8..74140b9db 100644 --- a/bot/cogs/jams.py +++ b/bot/cogs/jams.py @@ -52,7 +52,7 @@ class CodeJams(commands.Cog): @staticmethod async def get_category(ctx: commands.Context) -> CategoryChannel: - """Create Code Jam category when this don't exist and return this.""" + """Create a Code Jam category if it doesn't exist and return it.""" code_jam_category = utils.get(ctx.guild.categories, name="Code Jam") if code_jam_category is None: @@ -99,7 +99,7 @@ class CodeJams(commands.Cog): return team_channel_overwrites async def create_channels(self, ctx: commands.Context, team_name: str, members: t.List[Member]) -> str: - """Create team text and voice channel. Return name of text channel.""" + """Create team text and voice channels. Return the mention for the text channel.""" # Get permission overwrites and category team_channel_overwrites = self.get_overwrites(members, ctx) code_jam_category = await self.get_category(ctx) -- cgit v1.2.3 From 8bb1dca65121b0ceb9ba7a1f26642f7e0b73860c Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 13 Jun 2020 08:17:04 +0300 Subject: Jams: Use `Guild` instead `Context` for helper functions --- bot/cogs/jams.py | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py index 74140b9db..75cf8fe6b 100644 --- a/bot/cogs/jams.py +++ b/bot/cogs/jams.py @@ -1,7 +1,7 @@ import logging import typing as t -from discord import CategoryChannel, Member, PermissionOverwrite, utils +from discord import CategoryChannel, Guild, Member, PermissionOverwrite, utils from discord.ext import commands from more_itertools import unique_everseen @@ -41,8 +41,8 @@ class CodeJams(commands.Cog): ) return - team_channel = await self.create_channels(ctx, team_name, members) - await self.add_roles(ctx, members) + team_channel = await self.create_channels(ctx.guild, team_name, members) + await self.add_roles(ctx.guild, members) await ctx.send( f":ok_hand: Team created: {team_channel}\n" @@ -51,19 +51,19 @@ class CodeJams(commands.Cog): ) @staticmethod - async def get_category(ctx: commands.Context) -> CategoryChannel: + async def get_category(guild: Guild) -> CategoryChannel: """Create a Code Jam category if it doesn't exist and return it.""" - code_jam_category = utils.get(ctx.guild.categories, name="Code Jam") + code_jam_category = utils.get(guild.categories, name="Code Jam") if code_jam_category is None: log.info("Code Jam category not found, creating it.") category_overwrites = { - ctx.guild.default_role: PermissionOverwrite(read_messages=False), - ctx.guild.me: PermissionOverwrite(read_messages=True) + guild.default_role: PermissionOverwrite(read_messages=False), + guild.me: PermissionOverwrite(read_messages=True) } - code_jam_category = await ctx.guild.create_category_channel( + code_jam_category = await guild.create_category_channel( "Code Jam", overwrites=category_overwrites, reason="It's code jam time!" @@ -72,7 +72,7 @@ class CodeJams(commands.Cog): return code_jam_category @staticmethod - def get_overwrites(members: t.List[Member], ctx: commands.Context) -> t.Dict[Member, PermissionOverwrite]: + def get_overwrites(members: t.List[Member], guild: Guild) -> t.Dict[Member, PermissionOverwrite]: """Get Code Jam team channels permission overwrites.""" # First member is always the team leader team_channel_overwrites = { @@ -82,8 +82,8 @@ class CodeJams(commands.Cog): manage_webhooks=True, connect=True ), - ctx.guild.default_role: PermissionOverwrite(read_messages=False, connect=False), - ctx.guild.get_role(Roles.verified): PermissionOverwrite( + guild.default_role: PermissionOverwrite(read_messages=False, connect=False), + guild.get_role(Roles.verified): PermissionOverwrite( read_messages=False, connect=False ) @@ -98,14 +98,14 @@ class CodeJams(commands.Cog): return team_channel_overwrites - async def create_channels(self, ctx: commands.Context, team_name: str, members: t.List[Member]) -> str: + async def create_channels(self, guild: Guild, team_name: str, members: t.List[Member]) -> str: """Create team text and voice channels. Return the mention for the text channel.""" # Get permission overwrites and category - team_channel_overwrites = self.get_overwrites(members, ctx) - code_jam_category = await self.get_category(ctx) + team_channel_overwrites = self.get_overwrites(members, guild) + code_jam_category = await self.get_category(guild) # Create a text channel for the team - team_channel = await ctx.guild.create_text_channel( + team_channel = await guild.create_text_channel( team_name, overwrites=team_channel_overwrites, category=code_jam_category @@ -114,7 +114,7 @@ class CodeJams(commands.Cog): # Create a voice channel for the team team_voice_name = " ".join(team_name.split("-")).title() - await ctx.guild.create_voice_channel( + await guild.create_voice_channel( team_voice_name, overwrites=team_channel_overwrites, category=code_jam_category @@ -123,13 +123,13 @@ class CodeJams(commands.Cog): return team_channel.mention @staticmethod - async def add_roles(ctx: commands.Context, members: t.List[Member]) -> None: + async def add_roles(guild: Guild, members: t.List[Member]) -> None: """Assign team leader and jammer roles.""" # Assign team leader role - await members[0].add_roles(ctx.guild.get_role(Roles.team_leaders)) + await members[0].add_roles(guild.get_role(Roles.team_leaders)) # Assign rest of roles - jammer_role = ctx.guild.get_role(Roles.jammers) + jammer_role = guild.get_role(Roles.jammers) for member in members: await member.add_roles(jammer_role) -- cgit v1.2.3 From 9dbfe7da4cbc4d1820507e25ce56929b7fb55327 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 13 Jun 2020 08:26:19 +0300 Subject: Jam Tests: Update `Context` to `Guild` for tests too --- tests/bot/cogs/test_jams.py | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index 54fe0b5f2..17b86601f 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -47,23 +47,23 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): async def test_category_dont_exist(self): """Should create code jam category.""" self.utils_mock.get.return_value = None - await self.cog.get_category(self.ctx) - self.ctx.guild.create_category_channel.assert_awaited_once() - category_overwrites = self.ctx.guild.create_category_channel.call_args[1]["overwrites"] + await self.cog.get_category(self.guild) + self.guild.create_category_channel.assert_awaited_once() + category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"] - self.assertFalse(category_overwrites[self.ctx.guild.default_role].read_messages) - self.assertTrue(category_overwrites[self.ctx.guild.me].read_messages) + self.assertFalse(category_overwrites[self.guild.default_role].read_messages) + self.assertTrue(category_overwrites[self.guild.me].read_messages) async def test_category_channel_exist(self): """Should not try to create category channel.""" - await self.cog.get_category(self.ctx) - self.ctx.guild.create_category_channel.assert_not_awaited() + await self.cog.get_category(self.guild) + self.guild.create_category_channel.assert_not_awaited() async def test_channel_overwrites(self): """Should have correct permission overwrites for users and roles.""" leader = MockMember() members = [leader] + [MockMember() for _ in range(4)] - overwrites = self.cog.get_overwrites(members, self.ctx) + overwrites = self.cog.get_overwrites(members, self.guild) # Leader permission overwrites self.assertTrue(overwrites[leader].manage_messages) @@ -77,10 +77,10 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): self.assertTrue(overwrites[member].connect) # Everyone and verified role overwrite - self.assertFalse(overwrites[self.ctx.guild.default_role].read_messages) - self.assertFalse(overwrites[self.ctx.guild.default_role].connect) - self.assertFalse(overwrites[self.ctx.guild.get_role(Roles.verified)].read_messages) - self.assertFalse(overwrites[self.ctx.guild.get_role(Roles.verified)].connect) + self.assertFalse(overwrites[self.guild.default_role].read_messages) + self.assertFalse(overwrites[self.guild.default_role].connect) + self.assertFalse(overwrites[self.guild.get_role(Roles.verified)].read_messages) + self.assertFalse(overwrites[self.guild.get_role(Roles.verified)].connect) async def test_team_channels_creation(self): """Should create new voice and text channel for team.""" @@ -90,18 +90,18 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): self.cog.get_overwrites = MagicMock() self.cog.get_category = AsyncMock() self.ctx.guild.create_text_channel.return_value = MockTextChannel(mention="foobar-channel") - actual = await self.cog.create_channels(self.ctx, "my-team", members) + actual = await self.cog.create_channels(self.guild, "my-team", members) self.assertEqual("foobar-channel", actual) - self.cog.get_overwrites.assert_called_once_with(members, self.ctx) - self.cog.get_category.assert_awaited_once_with(self.ctx) + self.cog.get_overwrites.assert_called_once_with(members, self.guild) + self.cog.get_category.assert_awaited_once_with(self.guild) - self.ctx.guild.create_text_channel.assert_awaited_once_with( + self.guild.create_text_channel.assert_awaited_once_with( "my-team", overwrites=self.cog.get_overwrites.return_value, category=self.cog.get_category.return_value ) - self.ctx.guild.create_voice_channel.assert_awaited_once_with( + self.guild.create_voice_channel.assert_awaited_once_with( "My Team", overwrites=self.cog.get_overwrites.return_value, category=self.cog.get_category.return_value @@ -111,11 +111,11 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): """Should add team leader role to leader and jam role to every team member.""" leader_role = MockRole(name="Team Leader") jam_role = MockRole(name="Jammer") - self.ctx.guild.get_role.side_effect = [leader_role, jam_role] + self.guild.get_role.side_effect = [leader_role, jam_role] leader = MockMember() members = [leader] + [MockMember() for _ in range(4)] - await self.cog.add_roles(self.ctx, members) + await self.cog.add_roles(self.guild, members) leader.add_roles.assert_any_await(leader_role) for member in members: -- cgit v1.2.3 From d0f8272818095fc692e03ce2630fe2302b09393c Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 13 Jun 2020 08:31:16 +0300 Subject: Jams: Fix `get_overwrites` return type --- bot/cogs/jams.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py index 75cf8fe6b..a48dbc49a 100644 --- a/bot/cogs/jams.py +++ b/bot/cogs/jams.py @@ -1,7 +1,7 @@ import logging import typing as t -from discord import CategoryChannel, Guild, Member, PermissionOverwrite, utils +from discord import CategoryChannel, Guild, Member, PermissionOverwrite, Role, utils from discord.ext import commands from more_itertools import unique_everseen @@ -72,7 +72,7 @@ class CodeJams(commands.Cog): return code_jam_category @staticmethod - def get_overwrites(members: t.List[Member], guild: Guild) -> t.Dict[Member, PermissionOverwrite]: + def get_overwrites(members: t.List[Member], guild: Guild) -> t.Dict[t.Union[Member, Role], PermissionOverwrite]: """Get Code Jam team channels permission overwrites.""" # First member is always the team leader team_channel_overwrites = { -- cgit v1.2.3 From 2489b144b5bf131ec8b1b42e2ae1dd249cce4d3f Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 13 Jun 2020 08:35:35 +0300 Subject: Jam Tests: Simplify and make tests more secure --- tests/bot/cogs/test_jams.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index 17b86601f..2d2eebabf 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -42,7 +42,8 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): member = MockMember() await self.cog.createteam(*self.default_args, (member for _ in range(5))) self.ctx.send.assert_awaited_once() - self.utils_mock.get.assert_not_called() + self.cog.create_channels.assert_now_awaited() + self.cog.add_roles.assert_not_awaited() async def test_category_dont_exist(self): """Should create code jam category.""" @@ -125,12 +126,9 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): """Should call `ctx.send` when everything goes right.""" members = [MockMember() for _ in range(5)] await self.cog.createteam(self.cog, self.ctx, "foo", members) + self.cog.create_channel.assert_awaited_once() + self.cog.add_roles.assert_awaited_once() self.ctx.send.assert_awaited_once() - sent_string = self.ctx.send.call_args[0][0] - - self.assertIn(str(self.ctx.guild.create_text_channel.return_value.mention), sent_string) - self.assertIn(members[0].mention, sent_string) - self.assertIn(" ".join(member.mention for member in members[1:]), sent_string) class CodeJamSetup(unittest.TestCase): -- cgit v1.2.3 From 95ae613173bb87719155a95494fe448a45a2d6bc Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 13 Jun 2020 08:39:35 +0300 Subject: Jam Tests: Fix wrong function name and convert them to mocks --- tests/bot/cogs/test_jams.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index 2d2eebabf..a66658134 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -124,9 +124,11 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): async def test_result_sending(self): """Should call `ctx.send` when everything goes right.""" + self.cog.create_channels = AsyncMock() + self.cog.add_roles = AsyncMock() members = [MockMember() for _ in range(5)] await self.cog.createteam(self.cog, self.ctx, "foo", members) - self.cog.create_channel.assert_awaited_once() + self.cog.create_channels.assert_awaited_once() self.cog.add_roles.assert_awaited_once() self.ctx.send.assert_awaited_once() -- cgit v1.2.3 From ef67747e59892d1307246bcad4d32e245098ff58 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 13 Jun 2020 08:44:56 +0300 Subject: Jam Tests: Fix `test_duplicate_member_provided` assertions --- tests/bot/cogs/test_jams.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index a66658134..2f2cb4695 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -39,10 +39,12 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): async def test_duplicate_members_provided(self): """Should `ctx.send` and exit early because duplicate members provided and total there is only 1 member.""" + self.cog.create_channels = AsyncMock() + self.cog.add_roles = AsyncMock() member = MockMember() await self.cog.createteam(*self.default_args, (member for _ in range(5))) self.ctx.send.assert_awaited_once() - self.cog.create_channels.assert_now_awaited() + self.cog.create_channels.assert_not_awaited() self.cog.add_roles.assert_not_awaited() async def test_category_dont_exist(self): -- cgit v1.2.3 From e9724dad79e7dab3bb801f50770bb06cf8461019 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 13 Jun 2020 15:12:38 +0200 Subject: Incidents tests: use our own helper mocks No reason to build own MagicMocks as we already have helpers that more accurately mimic the mocked behaviour. --- tests/bot/cogs/moderation/test_incidents.py | 30 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 4c1f9bc07..d7cc84734 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -1,10 +1,9 @@ import enum import unittest -from unittest.mock import AsyncMock, MagicMock, call, patch - -import discord +from unittest.mock import MagicMock, call, patch from bot.cogs.moderation import incidents +from tests.helpers import MockMessage, MockReaction, MockTextChannel, MockUser @patch("bot.constants.Channels.incidents", 123) @@ -20,11 +19,10 @@ class TestIsIncident(unittest.TestCase): def setUp(self) -> None: """Prepare a mock message which should qualify as an incident.""" - self.incident = MagicMock( - discord.Message, - channel=MagicMock(discord.TextChannel, id=123), + self.incident = MockMessage( + channel=MockTextChannel(id=123), content="this is an incident", - author=MagicMock(discord.User, bot=False), + author=MockUser(bot=False), pinned=False, ) @@ -38,7 +36,7 @@ class TestIsIncident(unittest.TestCase): def test_is_incident_false_channel(self): """Message doesn't qualify if sent outside of #incidents.""" - self.incident.channel = MagicMock(discord.TextChannel, id=456) + self.incident.channel = MockTextChannel(id=456) self.check_false() def test_is_incident_false_content(self): @@ -48,7 +46,7 @@ class TestIsIncident(unittest.TestCase): def test_is_incident_false_author(self): """Message doesn't qualify if author is a bot.""" - self.incident.author = MagicMock(discord.User, bot=True) + self.incident.author = MockUser(bot=True) self.check_false() def test_is_incident_false_pinned(self): @@ -63,11 +61,11 @@ class TestOwnReactions(unittest.TestCase): def test_own_reactions(self): """Only bot's own emoji are extracted from the input incident.""" reactions = ( - MagicMock(discord.Reaction, emoji="A", me=True), - MagicMock(discord.Reaction, emoji="B", me=True), - MagicMock(discord.Reaction, emoji="C", me=False), + MockReaction(emoji="A", me=True), + MockReaction(emoji="B", me=True), + MockReaction(emoji="C", me=False), ) - message = MagicMock(discord.Message, reactions=reactions) + message = MockMessage(reactions=reactions) self.assertSetEqual(incidents.own_reactions(message), {"A", "B"}) @@ -82,7 +80,7 @@ class TestHasSignals(unittest.TestCase): def test_has_signals_true(self): """True when `own_reactions` returns all emoji in `ALLOWED_EMOJI`.""" - message = MagicMock(discord.Message) + message = MockMessage() own_reactions = MagicMock(return_value={"A", "B"}) with patch("bot.cogs.moderation.incidents.own_reactions", own_reactions): @@ -90,7 +88,7 @@ class TestHasSignals(unittest.TestCase): def test_has_signals_false(self): """False when `own_reactions` does not return all emoji in `ALLOWED_EMOJI`.""" - message = MagicMock(discord.Message) + message = MockMessage() own_reactions = MagicMock(return_value={"A", "C"}) with patch("bot.cogs.moderation.incidents.own_reactions", own_reactions): @@ -114,7 +112,7 @@ class TestAddSignals(unittest.IsolatedAsyncioTestCase): def setUp(self): """Prepare a mock incident message for tests to use.""" - self.incident = MagicMock(discord.Message, add_reaction=AsyncMock()) + self.incident = MockMessage() @patch("bot.cogs.moderation.incidents.own_reactions", MagicMock(return_value=set())) async def test_add_signals_missing(self): -- cgit v1.2.3 From 00a44226cb659319b9df5f568b0f67f9a0ed3360 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 13 Jun 2020 15:51:34 +0200 Subject: Incidents tests: improve mock `Signal` name & move def Let's make it clear that this is our own mock. We also move the definition to the top of the module. --- tests/bot/cogs/moderation/test_incidents.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index d7cc84734..a349c1cb7 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -6,6 +6,11 @@ from bot.cogs.moderation import incidents from tests.helpers import MockMessage, MockReaction, MockTextChannel, MockUser +class MockSignal(enum.Enum): + A = "A" + B = "B" + + @patch("bot.constants.Channels.incidents", 123) class TestIsIncident(unittest.TestCase): """ @@ -95,12 +100,7 @@ class TestHasSignals(unittest.TestCase): self.assertFalse(incidents.has_signals(message)) -class Signal(enum.Enum): - A = "A" - B = "B" - - -@patch("bot.cogs.moderation.incidents.Signal", Signal) +@patch("bot.cogs.moderation.incidents.Signal", MockSignal) class TestAddSignals(unittest.IsolatedAsyncioTestCase): """ Assertions for the `add_signals` coroutine. -- cgit v1.2.3 From c66b4a618503352803f73e9272a1d27b6e0a4d52 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 13 Jun 2020 17:24:31 +0200 Subject: Incidents tests: set up base class for `Incidents` For cleanliness, I've decided to make a separate class for each method. Since most tests will want to have an `Incident` instance ready, they can inherit the `setUp` from `TestIncidents`, which does not make any assertions on its own. --- tests/bot/cogs/moderation/test_incidents.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index a349c1cb7..d52932e0a 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -2,8 +2,8 @@ import enum import unittest from unittest.mock import MagicMock, call, patch -from bot.cogs.moderation import incidents -from tests.helpers import MockMessage, MockReaction, MockTextChannel, MockUser +from bot.cogs.moderation import Incidents, incidents +from tests.helpers import MockBot, MockMessage, MockReaction, MockTextChannel, MockUser class MockSignal(enum.Enum): @@ -131,3 +131,22 @@ class TestAddSignals(unittest.IsolatedAsyncioTestCase): """No emoji are added when all are present.""" await incidents.add_signals(self.incident) self.incident.add_reaction.assert_not_called() + + +class TestIncidents(unittest.IsolatedAsyncioTestCase): + """ + Tests for bound methods of the `Incidents` cog. + + Use this as a base class for `Incidents` tests - it will prepare a fresh instance + for each test function, but not make any assertions on its own. Tests can mutate + the instance as they wish. + """ + + def setUp(self): + """ + Prepare a fresh `Incidents` instance for each test. + + Note that this will not schedule `crawl_incidents` in the background, as everything + is being mocked. The `crawl_task` attribute will end up being None. + """ + self.cog_instance = Incidents(MockBot()) -- cgit v1.2.3 From 3c2d227cd067466668e3089f63a6548736edf8ab Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 13 Jun 2020 17:56:31 +0200 Subject: Incidents tests: write tests for `archive` --- tests/bot/cogs/moderation/test_incidents.py | 65 ++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index d52932e0a..7500235cf 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -1,9 +1,12 @@ import enum import unittest -from unittest.mock import MagicMock, call, patch +from unittest.mock import AsyncMock, MagicMock, call, patch + +import aiohttp +import discord from bot.cogs.moderation import Incidents, incidents -from tests.helpers import MockBot, MockMessage, MockReaction, MockTextChannel, MockUser +from tests.helpers import MockAsyncWebhook, MockBot, MockMessage, MockReaction, MockTextChannel, MockUser class MockSignal(enum.Enum): @@ -150,3 +153,61 @@ class TestIncidents(unittest.IsolatedAsyncioTestCase): is being mocked. The `crawl_task` attribute will end up being None. """ self.cog_instance = Incidents(MockBot()) + + +class TestArchive(TestIncidents): + """Tests for the `Incidents.archive` coroutine.""" + + async def test_archive_webhook_not_found(self): + """ + Method recovers and returns False when the webhook is not found. + + Implicitly, this also tests that the error is handled internally and doesn't + propagate out of the method, which is just as important. + """ + mock_404 = discord.NotFound( + response=MagicMock(aiohttp.ClientResponse), # Mock the erroneous response + message="Webhook not found", + ) + + self.cog_instance.bot.fetch_webhook = AsyncMock(side_effect=mock_404) + self.assertFalse(await self.cog_instance.archive(incident=MockMessage(), outcome=MagicMock())) + + async def test_archive_relays_incident(self): + """ + If webhook is found, method relays `incident` properly. + + This test will assert the following: + * The fetched webhook's `send` method is fed the correct arguments + * The message returned by `send` will have `outcome` reaction added + * Finally, the `archive` method returns True + + Assertions are made specifically in this order. + """ + webhook_message = MockMessage() # The message that will be returned by the webhook's `send` method + webhook = MockAsyncWebhook(send=AsyncMock(return_value=webhook_message)) + + self.cog_instance.bot.fetch_webhook = AsyncMock(return_value=webhook) # Patch in our webhook + + # Now we'll pas our own `incident` to `archive` and capture the return value + incident = MockMessage( + clean_content="pingless message", + content="pingful message", + author=MockUser(name="author_name", avatar_url="author_avatar"), + id=123, + ) + archive_return = await self.cog_instance.archive(incident, outcome=MagicMock(value="A")) + + # Check that the webhook was dispatched correctly + webhook.send.assert_called_once_with( + content="pingless message", + username="author_name", + avatar_url="author_avatar", + wait=True, + ) + + # Now check that the correct emoji was added to the relayed message + webhook_message.add_reaction.assert_called_once_with("A") + + # Finally check that the method returned True + self.assertTrue(archive_return) -- cgit v1.2.3 From b1f2b40623f45daf880186fa825fd69a7fc12092 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Apr 2020 22:23:27 -0700 Subject: Move code block formatting detection to a separate extension/cog It was really out of place in the BotCog, which is meant more for general, simple utility commands. --- bot/cogs/bot.py | 324 +--------------------------------------- bot/cogs/codeblock/__init__.py | 7 + bot/cogs/codeblock/cog.py | 332 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 342 insertions(+), 321 deletions(-) create mode 100644 bot/cogs/codeblock/__init__.py create mode 100644 bot/cogs/codeblock/cog.py diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index a79b37d25..89c691ccd 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -1,22 +1,15 @@ -import ast import logging -import re -import time -from typing import Optional, Tuple +from typing import Optional -from discord import Embed, Message, RawMessageUpdateEvent, TextChannel +from discord import Embed, TextChannel from discord.ext.commands import Cog, Context, command, group from bot.bot import Bot -from bot.cogs.token_remover import TokenRemover -from bot.constants import Categories, Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs +from bot.constants import Guild, MODERATION_ROLES, Roles, URLs from bot.decorators import with_role -from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) -RE_MARKDOWN = re.compile(r'([*_~`|>])') - class BotCog(Cog, name="Bot"): """Bot information commands.""" @@ -24,19 +17,6 @@ class BotCog(Cog, name="Bot"): def __init__(self, bot: Bot): self.bot = bot - # Stores allowed channels plus epoch time since last call. - self.channel_cooldowns = { - Channels.python_discussion: 0, - } - - # These channels will also work, but will not be subject to cooldown - self.channel_whitelist = ( - Channels.bot_commands, - ) - - # Stores improperly formatted Python codeblock message ids and the corresponding bot message - self.codeblock_message_ids = {} - @group(invoke_without_command=True, name="bot", hidden=True) @with_role(Roles.verified) async def botinfo_group(self, ctx: Context) -> None: @@ -77,304 +57,6 @@ class BotCog(Cog, name="Bot"): embed = Embed(description=text) await ctx.send(embed=embed) - def codeblock_stripping(self, msg: str, bad_ticks: bool) -> Optional[Tuple[Tuple[str, ...], str]]: - """ - Strip msg in order to find Python code. - - Tries to strip out Python code out of msg and returns the stripped block or - None if the block is a valid Python codeblock. - """ - if msg.count("\n") >= 3: - # Filtering valid Python codeblocks and exiting if a valid Python codeblock is found. - if re.search("```(?:py|python)\n(.*?)```", msg, re.IGNORECASE | re.DOTALL) and not bad_ticks: - log.trace( - "Someone wrote a message that was already a " - "valid Python syntax highlighted code block. No action taken." - ) - return None - - else: - # Stripping backticks from every line of the message. - log.trace(f"Stripping backticks from message.\n\n{msg}\n\n") - content = "" - for line in msg.splitlines(keepends=True): - content += line.strip("`") - - content = content.strip() - - # Remove "Python" or "Py" from start of the message if it exists. - log.trace(f"Removing 'py' or 'python' from message.\n\n{content}\n\n") - pycode = False - if content.lower().startswith("python"): - content = content[6:] - pycode = True - elif content.lower().startswith("py"): - content = content[2:] - pycode = True - - if pycode: - content = content.splitlines(keepends=True) - - # Check if there might be code in the first line, and preserve it. - first_line = content[0] - if " " in content[0]: - first_space = first_line.index(" ") - content[0] = first_line[first_space:] - content = "".join(content) - - # If there's no code we can just get rid of the first line. - else: - content = "".join(content[1:]) - - # Strip it again to remove any leading whitespace. This is neccessary - # if the first line of the message looked like ```python - old = content.strip() - - # Strips REPL code out of the message if there is any. - content, repl_code = self.repl_stripping(old) - if old != content: - return (content, old), repl_code - - # Try to apply indentation fixes to the code. - content = self.fix_indentation(content) - - # Check if the code contains backticks, if it does ignore the message. - if "`" in content: - log.trace("Detected ` inside the code, won't reply") - return None - else: - log.trace(f"Returning message.\n\n{content}\n\n") - return (content,), repl_code - - def fix_indentation(self, msg: str) -> str: - """Attempts to fix badly indented code.""" - def unindent(code: str, skip_spaces: int = 0) -> str: - """Unindents all code down to the number of spaces given in skip_spaces.""" - final = "" - current = code[0] - leading_spaces = 0 - - # Get numbers of spaces before code in the first line. - while current == " ": - current = code[leading_spaces + 1] - leading_spaces += 1 - leading_spaces -= skip_spaces - - # If there are any, remove that number of spaces from every line. - if leading_spaces > 0: - for line in code.splitlines(keepends=True): - line = line[leading_spaces:] - final += line - return final - else: - return code - - # Apply fix for "all lines are overindented" case. - msg = unindent(msg) - - # If the first line does not end with a colon, we can be - # certain the next line will be on the same indentation level. - # - # If it does end with a colon, we will need to indent all successive - # lines one additional level. - first_line = msg.splitlines()[0] - code = "".join(msg.splitlines(keepends=True)[1:]) - if not first_line.endswith(":"): - msg = f"{first_line}\n{unindent(code)}" - else: - msg = f"{first_line}\n{unindent(code, 4)}" - return msg - - def repl_stripping(self, msg: str) -> Tuple[str, bool]: - """ - Strip msg in order to extract Python code out of REPL output. - - Tries to strip out REPL Python code out of msg and returns the stripped msg. - - Returns True for the boolean if REPL code was found in the input msg. - """ - final = "" - for line in msg.splitlines(keepends=True): - if line.startswith(">>>") or line.startswith("..."): - final += line[4:] - log.trace(f"Formatted: \n\n{msg}\n\n to \n\n{final}\n\n") - if not final: - log.trace(f"Found no REPL code in \n\n{msg}\n\n") - return msg, False - else: - log.trace(f"Found REPL code in \n\n{msg}\n\n") - return final.rstrip(), True - - def has_bad_ticks(self, msg: Message) -> bool: - """Check to see if msg contains ticks that aren't '`'.""" - not_backticks = [ - "'''", '"""', "\u00b4\u00b4\u00b4", "\u2018\u2018\u2018", "\u2019\u2019\u2019", - "\u2032\u2032\u2032", "\u201c\u201c\u201c", "\u201d\u201d\u201d", "\u2033\u2033\u2033", - "\u3003\u3003\u3003" - ] - - return msg.content[:3] in not_backticks - - @Cog.listener() - async def on_message(self, msg: Message) -> None: - """ - Detect poorly formatted Python code in new messages. - - If poorly formatted code is detected, send the user a helpful message explaining how to do - properly formatted Python syntax highlighting codeblocks. - """ - is_help_channel = ( - getattr(msg.channel, "category", None) - and msg.channel.category.id in (Categories.help_available, Categories.help_in_use) - ) - parse_codeblock = ( - ( - is_help_channel - or msg.channel.id in self.channel_cooldowns - or msg.channel.id in self.channel_whitelist - ) - and not msg.author.bot - and len(msg.content.splitlines()) > 3 - and not TokenRemover.find_token_in_message(msg) - ) - - if parse_codeblock: # no token in the msg - on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300 - if not on_cooldown or DEBUG_MODE: - try: - if self.has_bad_ticks(msg): - ticks = msg.content[:3] - content = self.codeblock_stripping(f"```{msg.content[3:-3]}```", True) - if content is None: - return - - content, repl_code = content - - if len(content) == 2: - content = content[1] - else: - content = content[0] - - space_left = 204 - if len(content) >= space_left: - current_length = 0 - lines_walked = 0 - for line in content.splitlines(keepends=True): - if current_length + len(line) > space_left or lines_walked == 10: - break - current_length += len(line) - lines_walked += 1 - content = content[:current_length] + "#..." - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) - howto = ( - "It looks like you are trying to paste code into this channel.\n\n" - "You seem to be using the wrong symbols to indicate where the codeblock should start. " - f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n" - "**Here is an example of how it should look:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) - - else: - howto = "" - content = self.codeblock_stripping(msg.content, False) - if content is None: - return - - content, repl_code = content - # Attempts to parse the message into an AST node. - # Invalid Python code will raise a SyntaxError. - tree = ast.parse(content[0]) - - # Multiple lines of single words could be interpreted as expressions. - # This check is to avoid all nodes being parsed as expressions. - # (e.g. words over multiple lines) - if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: - # Shorten the code to 10 lines and/or 204 characters. - space_left = 204 - if content and repl_code: - content = content[1] - else: - content = content[0] - - if len(content) >= space_left: - current_length = 0 - lines_walked = 0 - for line in content.splitlines(keepends=True): - if current_length + len(line) > space_left or lines_walked == 10: - break - current_length += len(line) - lines_walked += 1 - content = content[:current_length] + "#..." - - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) - howto += ( - "It looks like you're trying to paste code into this channel.\n\n" - "Discord has support for Markdown, which allows you to post code with full " - "syntax highlighting. Please use these whenever you paste code, as this " - "helps improve the legibility and makes it easier for us to help you.\n\n" - f"**To do this, use the following method:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) - - log.debug(f"{msg.author} posted something that needed to be put inside python code " - "blocks. Sending the user some instructions.") - else: - log.trace("The code consists only of expressions, not sending instructions") - - if howto != "": - # Increase amount of codeblock correction in stats - self.bot.stats.incr("codeblock_corrections") - howto_embed = Embed(description=howto) - bot_message = await msg.channel.send(f"Hey {msg.author.mention}!", embed=howto_embed) - self.codeblock_message_ids[msg.id] = bot_message.id - - self.bot.loop.create_task( - wait_for_deletion(bot_message, user_ids=(msg.author.id,), client=self.bot) - ) - else: - return - - if msg.channel.id not in self.channel_whitelist: - self.channel_cooldowns[msg.channel.id] = time.time() - - except SyntaxError: - log.trace( - f"{msg.author} posted in a help channel, and when we tried to parse it as Python code, " - "ast.parse raised a SyntaxError. This probably just means it wasn't Python code. " - f"The message that was posted was:\n\n{msg.content}\n\n" - ) - - @Cog.listener() - async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: - """Check to see if an edited message (previously called out) still contains poorly formatted code.""" - if ( - # Checks to see if the message was called out by the bot - payload.message_id not in self.codeblock_message_ids - # Makes sure that there is content in the message - or payload.data.get("content") is None - # Makes sure there's a channel id in the message payload - or payload.data.get("channel_id") is None - ): - return - - # Retrieve channel and message objects for use later - channel = self.bot.get_channel(int(payload.data.get("channel_id"))) - user_message = await channel.fetch_message(payload.message_id) - - # Checks to see if the user has corrected their codeblock. If it's fixed, has_fixed_codeblock will be None - has_fixed_codeblock = self.codeblock_stripping(payload.data.get("content"), self.has_bad_ticks(user_message)) - - # If the message is fixed, delete the bot message and the entry from the id dictionary - if has_fixed_codeblock is None: - bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) - await bot_message.delete() - del self.codeblock_message_ids[payload.message_id] - log.trace("User's incorrect code block has been fixed. Removing bot formatting message.") - def setup(bot: Bot) -> None: """Load the Bot cog.""" diff --git a/bot/cogs/codeblock/__init__.py b/bot/cogs/codeblock/__init__.py new file mode 100644 index 000000000..466933191 --- /dev/null +++ b/bot/cogs/codeblock/__init__.py @@ -0,0 +1,7 @@ +from bot.bot import Bot +from .cog import CodeBlockCog + + +def setup(bot: Bot) -> None: + """Load the CodeBlockCog cog.""" + bot.add_cog(CodeBlockCog(bot)) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py new file mode 100644 index 000000000..7e35e24a9 --- /dev/null +++ b/bot/cogs/codeblock/cog.py @@ -0,0 +1,332 @@ +import ast +import logging +import re +import time +from typing import Optional, Tuple + +from discord import Embed, Message, RawMessageUpdateEvent +from discord.ext.commands import Bot, Cog + +from bot.cogs.token_remover import TokenRemover +from bot.constants import Categories, Channels, DEBUG_MODE +from bot.utils.messages import wait_for_deletion + +log = logging.getLogger(__name__) + +RE_MARKDOWN = re.compile(r'([*_~`|>])') + + +class CodeBlockCog(Cog, name="Code Block"): + """Detect improperly formatted code blocks and suggest proper formatting.""" + + def __init__(self, bot: Bot): + self.bot = bot + + # Stores allowed channels plus epoch time since last call. + self.channel_cooldowns = { + Channels.python_discussion: 0, + } + + # These channels will also work, but will not be subject to cooldown + self.channel_whitelist = ( + Channels.bot_commands, + ) + + # Stores improperly formatted Python codeblock message ids and the corresponding bot message + self.codeblock_message_ids = {} + + def codeblock_stripping(self, msg: str, bad_ticks: bool) -> Optional[Tuple[Tuple[str, ...], str]]: + """ + Strip msg in order to find Python code. + + Tries to strip out Python code out of msg and returns the stripped block or + None if the block is a valid Python codeblock. + """ + if msg.count("\n") >= 3: + # Filtering valid Python codeblocks and exiting if a valid Python codeblock is found. + if re.search("```(?:py|python)\n(.*?)```", msg, re.IGNORECASE | re.DOTALL) and not bad_ticks: + log.trace( + "Someone wrote a message that was already a " + "valid Python syntax highlighted code block. No action taken." + ) + return None + + else: + # Stripping backticks from every line of the message. + log.trace(f"Stripping backticks from message.\n\n{msg}\n\n") + content = "" + for line in msg.splitlines(keepends=True): + content += line.strip("`") + + content = content.strip() + + # Remove "Python" or "Py" from start of the message if it exists. + log.trace(f"Removing 'py' or 'python' from message.\n\n{content}\n\n") + pycode = False + if content.lower().startswith("python"): + content = content[6:] + pycode = True + elif content.lower().startswith("py"): + content = content[2:] + pycode = True + + if pycode: + content = content.splitlines(keepends=True) + + # Check if there might be code in the first line, and preserve it. + first_line = content[0] + if " " in content[0]: + first_space = first_line.index(" ") + content[0] = first_line[first_space:] + content = "".join(content) + + # If there's no code we can just get rid of the first line. + else: + content = "".join(content[1:]) + + # Strip it again to remove any leading whitespace. This is neccessary + # if the first line of the message looked like ```python + old = content.strip() + + # Strips REPL code out of the message if there is any. + content, repl_code = self.repl_stripping(old) + if old != content: + return (content, old), repl_code + + # Try to apply indentation fixes to the code. + content = self.fix_indentation(content) + + # Check if the code contains backticks, if it does ignore the message. + if "`" in content: + log.trace("Detected ` inside the code, won't reply") + return None + else: + log.trace(f"Returning message.\n\n{content}\n\n") + return (content,), repl_code + + def fix_indentation(self, msg: str) -> str: + """Attempts to fix badly indented code.""" + def unindent(code: str, skip_spaces: int = 0) -> str: + """Unindents all code down to the number of spaces given in skip_spaces.""" + final = "" + current = code[0] + leading_spaces = 0 + + # Get numbers of spaces before code in the first line. + while current == " ": + current = code[leading_spaces + 1] + leading_spaces += 1 + leading_spaces -= skip_spaces + + # If there are any, remove that number of spaces from every line. + if leading_spaces > 0: + for line in code.splitlines(keepends=True): + line = line[leading_spaces:] + final += line + return final + else: + return code + + # Apply fix for "all lines are overindented" case. + msg = unindent(msg) + + # If the first line does not end with a colon, we can be + # certain the next line will be on the same indentation level. + # + # If it does end with a colon, we will need to indent all successive + # lines one additional level. + first_line = msg.splitlines()[0] + code = "".join(msg.splitlines(keepends=True)[1:]) + if not first_line.endswith(":"): + msg = f"{first_line}\n{unindent(code)}" + else: + msg = f"{first_line}\n{unindent(code, 4)}" + return msg + + def repl_stripping(self, msg: str) -> Tuple[str, bool]: + """ + Strip msg in order to extract Python code out of REPL output. + + Tries to strip out REPL Python code out of msg and returns the stripped msg. + + Returns True for the boolean if REPL code was found in the input msg. + """ + final = "" + for line in msg.splitlines(keepends=True): + if line.startswith(">>>") or line.startswith("..."): + final += line[4:] + log.trace(f"Formatted: \n\n{msg}\n\n to \n\n{final}\n\n") + if not final: + log.trace(f"Found no REPL code in \n\n{msg}\n\n") + return msg, False + else: + log.trace(f"Found REPL code in \n\n{msg}\n\n") + return final.rstrip(), True + + def has_bad_ticks(self, msg: Message) -> bool: + """Check to see if msg contains ticks that aren't '`'.""" + not_backticks = [ + "'''", '"""', "\u00b4\u00b4\u00b4", "\u2018\u2018\u2018", "\u2019\u2019\u2019", + "\u2032\u2032\u2032", "\u201c\u201c\u201c", "\u201d\u201d\u201d", "\u2033\u2033\u2033", + "\u3003\u3003\u3003" + ] + + return msg.content[:3] in not_backticks + + @Cog.listener() + async def on_message(self, msg: Message) -> None: + """ + Detect poorly formatted Python code in new messages. + + If poorly formatted code is detected, send the user a helpful message explaining how to do + properly formatted Python syntax highlighting codeblocks. + """ + is_help_channel = ( + getattr(msg.channel, "category", None) + and msg.channel.category.id in (Categories.help_available, Categories.help_in_use) + ) + parse_codeblock = ( + ( + is_help_channel + or msg.channel.id in self.channel_cooldowns + or msg.channel.id in self.channel_whitelist + ) + and not msg.author.bot + and len(msg.content.splitlines()) > 3 + and not TokenRemover.find_token_in_message(msg) + ) + + if parse_codeblock: # no token in the msg + on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300 + if not on_cooldown or DEBUG_MODE: + try: + if self.has_bad_ticks(msg): + ticks = msg.content[:3] + content = self.codeblock_stripping(f"```{msg.content[3:-3]}```", True) + if content is None: + return + + content, repl_code = content + + if len(content) == 2: + content = content[1] + else: + content = content[0] + + space_left = 204 + if len(content) >= space_left: + current_length = 0 + lines_walked = 0 + for line in content.splitlines(keepends=True): + if current_length + len(line) > space_left or lines_walked == 10: + break + current_length += len(line) + lines_walked += 1 + content = content[:current_length] + "#..." + content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + howto = ( + "It looks like you are trying to paste code into this channel.\n\n" + "You seem to be using the wrong symbols to indicate where the codeblock should start. " + f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n" + "**Here is an example of how it should look:**\n" + f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" + f"```python\n{content}\n```" + ) + + else: + howto = "" + content = self.codeblock_stripping(msg.content, False) + if content is None: + return + + content, repl_code = content + # Attempts to parse the message into an AST node. + # Invalid Python code will raise a SyntaxError. + tree = ast.parse(content[0]) + + # Multiple lines of single words could be interpreted as expressions. + # This check is to avoid all nodes being parsed as expressions. + # (e.g. words over multiple lines) + if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: + # Shorten the code to 10 lines and/or 204 characters. + space_left = 204 + if content and repl_code: + content = content[1] + else: + content = content[0] + + if len(content) >= space_left: + current_length = 0 + lines_walked = 0 + for line in content.splitlines(keepends=True): + if current_length + len(line) > space_left or lines_walked == 10: + break + current_length += len(line) + lines_walked += 1 + content = content[:current_length] + "#..." + + content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + howto += ( + "It looks like you're trying to paste code into this channel.\n\n" + "Discord has support for Markdown, which allows you to post code with full " + "syntax highlighting. Please use these whenever you paste code, as this " + "helps improve the legibility and makes it easier for us to help you.\n\n" + f"**To do this, use the following method:**\n" + f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" + f"```python\n{content}\n```" + ) + + log.debug(f"{msg.author} posted something that needed to be put inside python code " + "blocks. Sending the user some instructions.") + else: + log.trace("The code consists only of expressions, not sending instructions") + + if howto != "": + howto_embed = Embed(description=howto) + bot_message = await msg.channel.send(f"Hey {msg.author.mention}!", embed=howto_embed) + self.codeblock_message_ids[msg.id] = bot_message.id + + self.bot.loop.create_task( + wait_for_deletion(bot_message, user_ids=(msg.author.id,), client=self.bot) + ) + else: + return + + if msg.channel.id not in self.channel_whitelist: + self.channel_cooldowns[msg.channel.id] = time.time() + + except SyntaxError: + log.trace( + f"{msg.author} posted in a help channel, and when we tried to parse it as Python code, " + "ast.parse raised a SyntaxError. This probably just means it wasn't Python code. " + f"The message that was posted was:\n\n{msg.content}\n\n" + ) + + @Cog.listener() + async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: + """Check to see if an edited message (previously called out) still contains poorly formatted code.""" + if ( + # Checks to see if the message was called out by the bot + payload.message_id not in self.codeblock_message_ids + # Makes sure that there is content in the message + or payload.data.get("content") is None + # Makes sure there's a channel id in the message payload + or payload.data.get("channel_id") is None + ): + return + + # Retrieve channel and message objects for use later + channel = self.bot.get_channel(int(payload.data.get("channel_id"))) + user_message = await channel.fetch_message(payload.message_id) + + # Checks to see if the user has corrected their codeblock. If it's fixed, has_fixed_codeblock will be None + has_fixed_codeblock = self.codeblock_stripping(payload.data.get("content"), self.has_bad_ticks(user_message)) + + # If the message is fixed, delete the bot message and the entry from the id dictionary + if has_fixed_codeblock is None: + bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) + await bot_message.delete() + del self.codeblock_message_ids[payload.message_id] + log.trace("User's incorrect code block has been fixed. Removing bot formatting message.") -- cgit v1.2.3 From 652bc5a1be4c181221ee40087a9c79d01fad10b8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Apr 2020 22:28:46 -0700 Subject: Code block: add helper function to check for help channels --- bot/cogs/codeblock/cog.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 7e35e24a9..af283120d 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -4,6 +4,7 @@ import re import time from typing import Optional, Tuple +import discord from discord import Embed, Message, RawMessageUpdateEvent from discord.ext.commands import Bot, Cog @@ -173,6 +174,14 @@ class CodeBlockCog(Cog, name="Code Block"): return msg.content[:3] in not_backticks + @staticmethod + def is_help_channel(channel: discord.TextChannel) -> bool: + """Return True if `channel` is in one of the help categories.""" + return ( + getattr(channel, "category", None) + and channel.category.id in (Categories.help_available, Categories.help_in_use) + ) + @Cog.listener() async def on_message(self, msg: Message) -> None: """ @@ -181,13 +190,9 @@ class CodeBlockCog(Cog, name="Code Block"): If poorly formatted code is detected, send the user a helpful message explaining how to do properly formatted Python syntax highlighting codeblocks. """ - is_help_channel = ( - getattr(msg.channel, "category", None) - and msg.channel.category.id in (Categories.help_available, Categories.help_in_use) - ) parse_codeblock = ( ( - is_help_channel + self.is_help_channel(msg.channel) or msg.channel.id in self.channel_cooldowns or msg.channel.id in self.channel_whitelist ) -- cgit v1.2.3 From 8af716254eb88bbf401665441a8d0ac1ca054671 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Apr 2020 22:34:11 -0700 Subject: Code block: add helper function to check channel is valid --- bot/cogs/codeblock/cog.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index af283120d..a1733ea99 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -182,6 +182,14 @@ class CodeBlockCog(Cog, name="Code Block"): and channel.category.id in (Categories.help_available, Categories.help_in_use) ) + def is_valid_channel(self, channel: discord.TextChannel) -> bool: + """Return True if `channel` is a help channel, may be on cooldown, or is whitelisted.""" + return ( + self.is_help_channel(channel) + or channel.id in self.channel_cooldowns + or channel.id in self.channel_whitelist + ) + @Cog.listener() async def on_message(self, msg: Message) -> None: """ @@ -191,11 +199,7 @@ class CodeBlockCog(Cog, name="Code Block"): properly formatted Python syntax highlighting codeblocks. """ parse_codeblock = ( - ( - self.is_help_channel(msg.channel) - or msg.channel.id in self.channel_cooldowns - or msg.channel.id in self.channel_whitelist - ) + self.is_valid_channel(msg.channel) and not msg.author.bot and len(msg.content.splitlines()) > 3 and not TokenRemover.find_token_in_message(msg) -- cgit v1.2.3 From 3b967c5228e439e127d096510d3097896536add3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Apr 2020 22:38:57 -0700 Subject: Code block: add helper function to check if msg should be parsed * Check for bot author first because it's a simpler/faster check --- bot/cogs/codeblock/cog.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index a1733ea99..9dd42fa81 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -190,6 +190,24 @@ class CodeBlockCog(Cog, name="Code Block"): or channel.id in self.channel_whitelist ) + def should_parse(self, message: discord.Message) -> bool: + """ + Return True if `message` should be parsed. + + A qualifying message: + + 1. Is not authored by a bot + 2. Is in a valid channel + 3. Has more than 3 lines + 4. Has no bot token + """ + return ( + not message.author.bot + and self.is_valid_channel(message.channel) + and len(message.content.splitlines()) > 3 + and not TokenRemover.find_token_in_message(message) + ) + @Cog.listener() async def on_message(self, msg: Message) -> None: """ @@ -198,14 +216,7 @@ class CodeBlockCog(Cog, name="Code Block"): If poorly formatted code is detected, send the user a helpful message explaining how to do properly formatted Python syntax highlighting codeblocks. """ - parse_codeblock = ( - self.is_valid_channel(msg.channel) - and not msg.author.bot - and len(msg.content.splitlines()) > 3 - and not TokenRemover.find_token_in_message(msg) - ) - - if parse_codeblock: # no token in the msg + if self.should_parse(msg): # no token in the msg on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300 if not on_cooldown or DEBUG_MODE: try: -- cgit v1.2.3 From 76eff088a6e2aa832165087d441effee26d8fead Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Apr 2020 22:42:16 -0700 Subject: Code block: add helper function to check for channel cooldown --- bot/cogs/codeblock/cog.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 9dd42fa81..be7c3df84 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -182,6 +182,14 @@ class CodeBlockCog(Cog, name="Code Block"): and channel.category.id in (Categories.help_available, Categories.help_in_use) ) + def is_on_cooldown(self, channel: discord.TextChannel) -> bool: + """ + Return True if an embed was sent for `channel` in the last 300 seconds. + + Note: only channels in the `channel_cooldowns` have cooldowns enabled. + """ + return (time.time() - self.channel_cooldowns.get(channel.id, 0)) < 300 + def is_valid_channel(self, channel: discord.TextChannel) -> bool: """Return True if `channel` is a help channel, may be on cooldown, or is whitelisted.""" return ( @@ -217,8 +225,7 @@ class CodeBlockCog(Cog, name="Code Block"): properly formatted Python syntax highlighting codeblocks. """ if self.should_parse(msg): # no token in the msg - on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300 - if not on_cooldown or DEBUG_MODE: + if not self.is_on_cooldown(msg.channel) or DEBUG_MODE: try: if self.has_bad_ticks(msg): ticks = msg.content[:3] -- cgit v1.2.3 From 8f79a8bf5f1a7372c6de7d768f1593d5da599789 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Apr 2020 22:43:25 -0700 Subject: Code block: invert conditions to reduce nesting --- bot/cogs/codeblock/cog.py | 209 ++++++++++++++++++++++++---------------------- 1 file changed, 107 insertions(+), 102 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index be7c3df84..36c761764 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -224,113 +224,118 @@ class CodeBlockCog(Cog, name="Code Block"): If poorly formatted code is detected, send the user a helpful message explaining how to do properly formatted Python syntax highlighting codeblocks. """ - if self.should_parse(msg): # no token in the msg - if not self.is_on_cooldown(msg.channel) or DEBUG_MODE: - try: - if self.has_bad_ticks(msg): - ticks = msg.content[:3] - content = self.codeblock_stripping(f"```{msg.content[3:-3]}```", True) - if content is None: - return - - content, repl_code = content - - if len(content) == 2: - content = content[1] - else: - content = content[0] - - space_left = 204 - if len(content) >= space_left: - current_length = 0 - lines_walked = 0 - for line in content.splitlines(keepends=True): - if current_length + len(line) > space_left or lines_walked == 10: - break - current_length += len(line) - lines_walked += 1 - content = content[:current_length] + "#..." - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) - howto = ( - "It looks like you are trying to paste code into this channel.\n\n" - "You seem to be using the wrong symbols to indicate where the codeblock should start. " - f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n" - "**Here is an example of how it should look:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) + if not self.should_parse(msg): + return - else: - howto = "" - content = self.codeblock_stripping(msg.content, False) - if content is None: - return - - content, repl_code = content - # Attempts to parse the message into an AST node. - # Invalid Python code will raise a SyntaxError. - tree = ast.parse(content[0]) - - # Multiple lines of single words could be interpreted as expressions. - # This check is to avoid all nodes being parsed as expressions. - # (e.g. words over multiple lines) - if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: - # Shorten the code to 10 lines and/or 204 characters. - space_left = 204 - if content and repl_code: - content = content[1] - else: - content = content[0] - - if len(content) >= space_left: - current_length = 0 - lines_walked = 0 - for line in content.splitlines(keepends=True): - if current_length + len(line) > space_left or lines_walked == 10: - break - current_length += len(line) - lines_walked += 1 - content = content[:current_length] + "#..." - - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) - howto += ( - "It looks like you're trying to paste code into this channel.\n\n" - "Discord has support for Markdown, which allows you to post code with full " - "syntax highlighting. Please use these whenever you paste code, as this " - "helps improve the legibility and makes it easier for us to help you.\n\n" - f"**To do this, use the following method:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) - - log.debug(f"{msg.author} posted something that needed to be put inside python code " - "blocks. Sending the user some instructions.") - else: - log.trace("The code consists only of expressions, not sending instructions") - - if howto != "": - howto_embed = Embed(description=howto) - bot_message = await msg.channel.send(f"Hey {msg.author.mention}!", embed=howto_embed) - self.codeblock_message_ids[msg.id] = bot_message.id - - self.bot.loop.create_task( - wait_for_deletion(bot_message, user_ids=(msg.author.id,), client=self.bot) - ) - else: - return + # When debugging, ignore cooldowns. + if self.is_on_cooldown(msg.channel) and not DEBUG_MODE: + return + + try: + if self.has_bad_ticks(msg): + ticks = msg.content[:3] + content = self.codeblock_stripping(f"```{msg.content[3:-3]}```", True) + if content is None: + return - if msg.channel.id not in self.channel_whitelist: - self.channel_cooldowns[msg.channel.id] = time.time() + content, repl_code = content - except SyntaxError: - log.trace( - f"{msg.author} posted in a help channel, and when we tried to parse it as Python code, " - "ast.parse raised a SyntaxError. This probably just means it wasn't Python code. " - f"The message that was posted was:\n\n{msg.content}\n\n" + if len(content) == 2: + content = content[1] + else: + content = content[0] + + space_left = 204 + if len(content) >= space_left: + current_length = 0 + lines_walked = 0 + for line in content.splitlines(keepends=True): + if current_length + len(line) > space_left or lines_walked == 10: + break + current_length += len(line) + lines_walked += 1 + content = content[:current_length] + "#..." + content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + howto = ( + "It looks like you are trying to paste code into this channel.\n\n" + "You seem to be using the wrong symbols to indicate where the codeblock should start. " + f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n" + "**Here is an example of how it should look:**\n" + f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" + f"```python\n{content}\n```" + ) + + else: + howto = "" + content = self.codeblock_stripping(msg.content, False) + if content is None: + return + + content, repl_code = content + # Attempts to parse the message into an AST node. + # Invalid Python code will raise a SyntaxError. + tree = ast.parse(content[0]) + + # Multiple lines of single words could be interpreted as expressions. + # This check is to avoid all nodes being parsed as expressions. + # (e.g. words over multiple lines) + if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: + # Shorten the code to 10 lines and/or 204 characters. + space_left = 204 + if content and repl_code: + content = content[1] + else: + content = content[0] + + if len(content) >= space_left: + current_length = 0 + lines_walked = 0 + for line in content.splitlines(keepends=True): + if current_length + len(line) > space_left or lines_walked == 10: + break + current_length += len(line) + lines_walked += 1 + content = content[:current_length] + "#..." + + content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + howto += ( + "It looks like you're trying to paste code into this channel.\n\n" + "Discord has support for Markdown, which allows you to post code with full " + "syntax highlighting. Please use these whenever you paste code, as this " + "helps improve the legibility and makes it easier for us to help you.\n\n" + f"**To do this, use the following method:**\n" + f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" + f"```python\n{content}\n```" ) + log.debug(f"{msg.author} posted something that needed to be put inside python code " + "blocks. Sending the user some instructions.") + else: + log.trace("The code consists only of expressions, not sending instructions") + + if howto != "": + howto_embed = Embed(description=howto) + bot_message = await msg.channel.send(f"Hey {msg.author.mention}!", embed=howto_embed) + self.codeblock_message_ids[msg.id] = bot_message.id + + self.bot.loop.create_task( + wait_for_deletion(bot_message, user_ids=(msg.author.id,), client=self.bot) + ) + else: + return + + if msg.channel.id not in self.channel_whitelist: + self.channel_cooldowns[msg.channel.id] = time.time() + + except SyntaxError: + log.trace( + f"{msg.author} posted in a help channel, and when we tried to parse it as Python code, " + "ast.parse raised a SyntaxError. This probably just means it wasn't Python code. " + f"The message that was posted was:\n\n{msg.content}\n\n" + ) + @Cog.listener() async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: """Check to see if an edited message (previously called out) still contains poorly formatted code.""" -- cgit v1.2.3 From 644918f7a4952a8c5eb96c2c1181a3784e73cfb5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Apr 2020 22:53:05 -0700 Subject: Code block: add helper function to send the embed --- bot/cogs/codeblock/cog.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 36c761764..a4cd743e4 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -198,6 +198,20 @@ class CodeBlockCog(Cog, name="Code Block"): or channel.id in self.channel_whitelist ) + async def send_guide_embed(self, message: discord.Message, description: str) -> None: + """ + Send an embed with `description` as a guide for an improperly formatted `message`. + + The embed will be deleted automatically after 5 minutes. + """ + embed = Embed(description=description) + bot_message = await message.channel.send(f"Hey {message.author.mention}!", embed=embed) + self.codeblock_message_ids[message.id] = bot_message.id + + self.bot.loop.create_task( + wait_for_deletion(bot_message, user_ids=(message.author.id,), client=self.bot) + ) + def should_parse(self, message: discord.Message) -> bool: """ Return True if `message` should be parsed. @@ -316,13 +330,7 @@ class CodeBlockCog(Cog, name="Code Block"): log.trace("The code consists only of expressions, not sending instructions") if howto != "": - howto_embed = Embed(description=howto) - bot_message = await msg.channel.send(f"Hey {msg.author.mention}!", embed=howto_embed) - self.codeblock_message_ids[msg.id] = bot_message.id - - self.bot.loop.create_task( - wait_for_deletion(bot_message, user_ids=(msg.author.id,), client=self.bot) - ) + await self.send_guide_embed(msg, howto) else: return -- cgit v1.2.3 From fc5d7407dc0e52461c8940cf2eabb832e9c7a4a7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Apr 2020 22:56:35 -0700 Subject: Code block: move final send/cooldown code outside the try-except Reduces nesting for improved readability. The code would have never thrown a syntax error in the manner expected anyway. --- bot/cogs/codeblock/cog.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index a4cd743e4..312a7034e 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -328,21 +328,18 @@ class CodeBlockCog(Cog, name="Code Block"): "blocks. Sending the user some instructions.") else: log.trace("The code consists only of expressions, not sending instructions") - - if howto != "": - await self.send_guide_embed(msg, howto) - else: - return - - if msg.channel.id not in self.channel_whitelist: - self.channel_cooldowns[msg.channel.id] = time.time() - except SyntaxError: log.trace( f"{msg.author} posted in a help channel, and when we tried to parse it as Python code, " "ast.parse raised a SyntaxError. This probably just means it wasn't Python code. " f"The message that was posted was:\n\n{msg.content}\n\n" ) + return + + if howto: + await self.send_guide_embed(msg, howto) + if msg.channel.id not in self.channel_whitelist: + self.channel_cooldowns[msg.channel.id] = time.time() @Cog.listener() async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: -- cgit v1.2.3 From aa37ffc42abf70135d17c3810bb2d35f810f965f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 14 Apr 2020 09:38:24 -0700 Subject: Code block: move bad ticks message creation to a new function --- bot/cogs/codeblock/cog.py | 70 +++++++++++++++++++++++++---------------------- 1 file changed, 37 insertions(+), 33 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 312a7034e..ddbe081dd 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -105,6 +105,42 @@ class CodeBlockCog(Cog, name="Code Block"): log.trace(f"Returning message.\n\n{content}\n\n") return (content,), repl_code + def format_bad_ticks_message(self, message: discord.Message) -> Optional[str]: + """Return the guide message to output for bad code block ticks in `message`.""" + ticks = message.content[:3] + content = self.codeblock_stripping(f"```{message.content[3:-3]}```", True) + if content is None: + return + + content, repl_code = content + + if len(content) == 2: + content = content[1] + else: + content = content[0] + + space_left = 204 + if len(content) >= space_left: + current_length = 0 + lines_walked = 0 + for line in content.splitlines(keepends=True): + if current_length + len(line) > space_left or lines_walked == 10: + break + current_length += len(line) + lines_walked += 1 + content = content[:current_length] + "#..." + content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + + return ( + "It looks like you are trying to paste code into this channel.\n\n" + "You seem to be using the wrong symbols to indicate where the codeblock should start. " + f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n" + "**Here is an example of how it should look:**\n" + f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" + f"```python\n{content}\n```" + ) + def fix_indentation(self, msg: str) -> str: """Attempts to fix badly indented code.""" def unindent(code: str, skip_spaces: int = 0) -> str: @@ -247,39 +283,7 @@ class CodeBlockCog(Cog, name="Code Block"): try: if self.has_bad_ticks(msg): - ticks = msg.content[:3] - content = self.codeblock_stripping(f"```{msg.content[3:-3]}```", True) - if content is None: - return - - content, repl_code = content - - if len(content) == 2: - content = content[1] - else: - content = content[0] - - space_left = 204 - if len(content) >= space_left: - current_length = 0 - lines_walked = 0 - for line in content.splitlines(keepends=True): - if current_length + len(line) > space_left or lines_walked == 10: - break - current_length += len(line) - lines_walked += 1 - content = content[:current_length] + "#..." - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) - howto = ( - "It looks like you are trying to paste code into this channel.\n\n" - "You seem to be using the wrong symbols to indicate where the codeblock should start. " - f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n" - "**Here is an example of how it should look:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) - + howto = self.format_bad_ticks_message(msg) else: howto = "" content = self.codeblock_stripping(msg.content, False) -- cgit v1.2.3 From 254fa81c691d387fa5fae661b56d642da7375863 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 14 Apr 2020 09:45:25 -0700 Subject: Code block: move standard guide message creation to a new function * Rename `howto` variable to `description` --- bot/cogs/codeblock/cog.py | 105 ++++++++++++++++++++++++---------------------- 1 file changed, 55 insertions(+), 50 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index ddbe081dd..7a9ca8e04 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -141,6 +141,57 @@ class CodeBlockCog(Cog, name="Code Block"): f"```python\n{content}\n```" ) + def format_guide_message(self, message: discord.Message) -> Optional[str]: + """Return the guide message to output for a poorly formatted code block in `message`.""" + content = self.codeblock_stripping(message.content, False) + if content is None: + return + + content, repl_code = content + # Attempts to parse the message into an AST node. + # Invalid Python code will raise a SyntaxError. + tree = ast.parse(content[0]) + + # Multiple lines of single words could be interpreted as expressions. + # This check is to avoid all nodes being parsed as expressions. + # (e.g. words over multiple lines) + if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: + # Shorten the code to 10 lines and/or 204 characters. + space_left = 204 + if content and repl_code: + content = content[1] + else: + content = content[0] + + if len(content) >= space_left: + current_length = 0 + lines_walked = 0 + for line in content.splitlines(keepends=True): + if current_length + len(line) > space_left or lines_walked == 10: + break + current_length += len(line) + lines_walked += 1 + content = content[:current_length] + "#..." + + log.debug( + f"{message.author} posted something that needed to be put inside python code " + f"blocks. Sending the user some instructions." + ) + + content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + return ( + "It looks like you're trying to paste code into this channel.\n\n" + "Discord has support for Markdown, which allows you to post code with full " + "syntax highlighting. Please use these whenever you paste code, as this " + "helps improve the legibility and makes it easier for us to help you.\n\n" + f"**To do this, use the following method:**\n" + f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" + f"```python\n{content}\n```" + ) + else: + log.trace("The code consists only of expressions, not sending instructions") + def fix_indentation(self, msg: str) -> str: """Attempts to fix badly indented code.""" def unindent(code: str, skip_spaces: int = 0) -> str: @@ -283,55 +334,9 @@ class CodeBlockCog(Cog, name="Code Block"): try: if self.has_bad_ticks(msg): - howto = self.format_bad_ticks_message(msg) + description = self.format_bad_ticks_message(msg) else: - howto = "" - content = self.codeblock_stripping(msg.content, False) - if content is None: - return - - content, repl_code = content - # Attempts to parse the message into an AST node. - # Invalid Python code will raise a SyntaxError. - tree = ast.parse(content[0]) - - # Multiple lines of single words could be interpreted as expressions. - # This check is to avoid all nodes being parsed as expressions. - # (e.g. words over multiple lines) - if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: - # Shorten the code to 10 lines and/or 204 characters. - space_left = 204 - if content and repl_code: - content = content[1] - else: - content = content[0] - - if len(content) >= space_left: - current_length = 0 - lines_walked = 0 - for line in content.splitlines(keepends=True): - if current_length + len(line) > space_left or lines_walked == 10: - break - current_length += len(line) - lines_walked += 1 - content = content[:current_length] + "#..." - - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) - howto += ( - "It looks like you're trying to paste code into this channel.\n\n" - "Discord has support for Markdown, which allows you to post code with full " - "syntax highlighting. Please use these whenever you paste code, as this " - "helps improve the legibility and makes it easier for us to help you.\n\n" - f"**To do this, use the following method:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) - - log.debug(f"{msg.author} posted something that needed to be put inside python code " - "blocks. Sending the user some instructions.") - else: - log.trace("The code consists only of expressions, not sending instructions") + description = self.format_guide_message(msg) except SyntaxError: log.trace( f"{msg.author} posted in a help channel, and when we tried to parse it as Python code, " @@ -340,8 +345,8 @@ class CodeBlockCog(Cog, name="Code Block"): ) return - if howto: - await self.send_guide_embed(msg, howto) + if description: + await self.send_guide_embed(msg, description) if msg.channel.id not in self.channel_whitelist: self.channel_cooldowns[msg.channel.id] = time.time() -- cgit v1.2.3 From d0232f76cdf09ecf61ca1329f09f6f78f3e3cf23 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 14 Apr 2020 09:58:46 -0700 Subject: Code block: make invalid backticks a constant set A set should be faster since it's being used to test for membership. A constant just means it won't need to be redefined every time the function is called. * Make `has_bad_ticks` a static method * Add comments describing characters represented by the Unicode escapes --- bot/cogs/codeblock/cog.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 7a9ca8e04..e435d036c 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -15,6 +15,18 @@ from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) RE_MARKDOWN = re.compile(r'([*_~`|>])') +INVALID_BACKTICKS = { + "'''", + '"""', + "\u00b4\u00b4\u00b4", # ACUTE ACCENT + "\u2018\u2018\u2018", # LEFT SINGLE QUOTATION MARK + "\u2019\u2019\u2019", # RIGHT SINGLE QUOTATION MARK + "\u2032\u2032\u2032", # PRIME + "\u201c\u201c\u201c", # LEFT DOUBLE QUOTATION MARK + "\u201d\u201d\u201d", # RIGHT DOUBLE QUOTATION MARK + "\u2033\u2033\u2033", # DOUBLE PRIME + "\u3003\u3003\u3003", # VERTICAL KANA REPEAT MARK UPPER HALF +} class CodeBlockCog(Cog, name="Code Block"): @@ -251,15 +263,10 @@ class CodeBlockCog(Cog, name="Code Block"): log.trace(f"Found REPL code in \n\n{msg}\n\n") return final.rstrip(), True - def has_bad_ticks(self, msg: Message) -> bool: - """Check to see if msg contains ticks that aren't '`'.""" - not_backticks = [ - "'''", '"""', "\u00b4\u00b4\u00b4", "\u2018\u2018\u2018", "\u2019\u2019\u2019", - "\u2032\u2032\u2032", "\u201c\u201c\u201c", "\u201d\u201d\u201d", "\u2033\u2033\u2033", - "\u3003\u3003\u3003" - ] - - return msg.content[:3] in not_backticks + @staticmethod + def has_bad_ticks(message: discord.Message) -> bool: + """Return True if `message` starts with 3 characters which look like but aren't '`'.""" + return message.content[:3] in INVALID_BACKTICKS @staticmethod def is_help_channel(channel: discord.TextChannel) -> bool: -- cgit v1.2.3 From 66a3af006a7e9928afd55d0f4ccf48d886b79487 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 14 Apr 2020 10:02:37 -0700 Subject: Code block: simplify log message --- bot/cogs/codeblock/cog.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index e435d036c..c49d7574c 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -346,9 +346,8 @@ class CodeBlockCog(Cog, name="Code Block"): description = self.format_guide_message(msg) except SyntaxError: log.trace( - f"{msg.author} posted in a help channel, and when we tried to parse it as Python code, " - "ast.parse raised a SyntaxError. This probably just means it wasn't Python code. " - f"The message that was posted was:\n\n{msg.content}\n\n" + f"SyntaxError while parsing code block sent by {msg.author}; " + f"code posted probably just wasn't Python:\n\n{msg.content}\n\n" ) return -- cgit v1.2.3 From 381872deedd39c171f3fff3312c6049c19c4371f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 15 Apr 2020 21:07:38 -0700 Subject: Code block: ignore if code block has *any* language If the code was valid Python syntax, the guide embed would be sent despite a non-Python language being explicitly specified for the code block by the message author. * Make the code block language regex a compiled pattern constant Fixes #829 --- bot/cogs/codeblock/cog.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index c49d7574c..fc515c8df 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -15,6 +15,7 @@ from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) RE_MARKDOWN = re.compile(r'([*_~`|>])') +RE_CODE_BLOCK_LANGUAGE = re.compile(r"```(?:[^\W_])\n(.*?)```", re.DOTALL) INVALID_BACKTICKS = { "'''", '"""', @@ -57,11 +58,8 @@ class CodeBlockCog(Cog, name="Code Block"): """ if msg.count("\n") >= 3: # Filtering valid Python codeblocks and exiting if a valid Python codeblock is found. - if re.search("```(?:py|python)\n(.*?)```", msg, re.IGNORECASE | re.DOTALL) and not bad_ticks: - log.trace( - "Someone wrote a message that was already a " - "valid Python syntax highlighted code block. No action taken." - ) + if RE_CODE_BLOCK_LANGUAGE.search(msg) and not bad_ticks: + log.trace("Code block already has valid syntax highlighting; no action taken") return None else: -- cgit v1.2.3 From e3c0f7c00b78484f8d802e3e70e0b711122580ba Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Apr 2020 08:50:04 -0700 Subject: Code block: use a more efficient line count check --- bot/cogs/codeblock/cog.py | 116 +++++++++++++++++++++++----------------------- 1 file changed, 59 insertions(+), 57 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index fc515c8df..6699abd2f 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -56,64 +56,66 @@ class CodeBlockCog(Cog, name="Code Block"): Tries to strip out Python code out of msg and returns the stripped block or None if the block is a valid Python codeblock. """ - if msg.count("\n") >= 3: - # Filtering valid Python codeblocks and exiting if a valid Python codeblock is found. - if RE_CODE_BLOCK_LANGUAGE.search(msg) and not bad_ticks: - log.trace("Code block already has valid syntax highlighting; no action taken") - return None + if len(msg.split("\n", 3)) <= 3: + return None - else: - # Stripping backticks from every line of the message. - log.trace(f"Stripping backticks from message.\n\n{msg}\n\n") - content = "" - for line in msg.splitlines(keepends=True): - content += line.strip("`") - - content = content.strip() - - # Remove "Python" or "Py" from start of the message if it exists. - log.trace(f"Removing 'py' or 'python' from message.\n\n{content}\n\n") - pycode = False - if content.lower().startswith("python"): - content = content[6:] - pycode = True - elif content.lower().startswith("py"): - content = content[2:] - pycode = True - - if pycode: - content = content.splitlines(keepends=True) - - # Check if there might be code in the first line, and preserve it. - first_line = content[0] - if " " in content[0]: - first_space = first_line.index(" ") - content[0] = first_line[first_space:] - content = "".join(content) - - # If there's no code we can just get rid of the first line. - else: - content = "".join(content[1:]) - - # Strip it again to remove any leading whitespace. This is neccessary - # if the first line of the message looked like ```python - old = content.strip() - - # Strips REPL code out of the message if there is any. - content, repl_code = self.repl_stripping(old) - if old != content: - return (content, old), repl_code - - # Try to apply indentation fixes to the code. - content = self.fix_indentation(content) - - # Check if the code contains backticks, if it does ignore the message. - if "`" in content: - log.trace("Detected ` inside the code, won't reply") - return None + # Filtering valid Python codeblocks and exiting if a valid Python codeblock is found. + if RE_CODE_BLOCK_LANGUAGE.search(msg) and not bad_ticks: + log.trace("Code block already has valid syntax highlighting; no action taken") + return None + + else: + # Stripping backticks from every line of the message. + log.trace(f"Stripping backticks from message.\n\n{msg}\n\n") + content = "" + for line in msg.splitlines(keepends=True): + content += line.strip("`") + + content = content.strip() + + # Remove "Python" or "Py" from start of the message if it exists. + log.trace(f"Removing 'py' or 'python' from message.\n\n{content}\n\n") + pycode = False + if content.lower().startswith("python"): + content = content[6:] + pycode = True + elif content.lower().startswith("py"): + content = content[2:] + pycode = True + + if pycode: + content = content.splitlines(keepends=True) + + # Check if there might be code in the first line, and preserve it. + first_line = content[0] + if " " in content[0]: + first_space = first_line.index(" ") + content[0] = first_line[first_space:] + content = "".join(content) + + # If there's no code we can just get rid of the first line. else: - log.trace(f"Returning message.\n\n{content}\n\n") - return (content,), repl_code + content = "".join(content[1:]) + + # Strip it again to remove any leading whitespace. This is neccessary + # if the first line of the message looked like ```python + old = content.strip() + + # Strips REPL code out of the message if there is any. + content, repl_code = self.repl_stripping(old) + if old != content: + return (content, old), repl_code + + # Try to apply indentation fixes to the code. + content = self.fix_indentation(content) + + # Check if the code contains backticks, if it does ignore the message. + if "`" in content: + log.trace("Detected ` inside the code, won't reply") + return None + else: + log.trace(f"Returning message.\n\n{content}\n\n") + return (content,), repl_code def format_bad_ticks_message(self, message: discord.Message) -> Optional[str]: """Return the guide message to output for bad code block ticks in `message`.""" @@ -318,7 +320,7 @@ class CodeBlockCog(Cog, name="Code Block"): return ( not message.author.bot and self.is_valid_channel(message.channel) - and len(message.content.splitlines()) > 3 + and len(message.content.split("\n", 3)) > 3 and not TokenRemover.find_token_in_message(message) ) -- cgit v1.2.3 From b914d236b8129ae2616424629922db81a79eeead Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 May 2020 20:44:15 -0700 Subject: Code block: fix code block language regex It was missing a quantifier to match more than 1 character. --- bot/cogs/codeblock/cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 6699abd2f..cde16bd9f 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -15,7 +15,7 @@ from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) RE_MARKDOWN = re.compile(r'([*_~`|>])') -RE_CODE_BLOCK_LANGUAGE = re.compile(r"```(?:[^\W_])\n(.*?)```", re.DOTALL) +RE_CODE_BLOCK_LANGUAGE = re.compile(r"```(?:[^\W_]+)\n(.*?)```", re.DOTALL) INVALID_BACKTICKS = { "'''", '"""', -- cgit v1.2.3 From 964d14a150edf583c7211ddaad74ce67ee98cd80 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 May 2020 21:22:40 -0700 Subject: Code block: add regex to search for any code blocks This regex supports both valid and invalid ticks. The ticks are in a group so it's later possible to detect if valid ones were used. --- bot/cogs/codeblock/cog.py | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index cde16bd9f..292735f3f 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -16,18 +16,31 @@ log = logging.getLogger(__name__) RE_MARKDOWN = re.compile(r'([*_~`|>])') RE_CODE_BLOCK_LANGUAGE = re.compile(r"```(?:[^\W_]+)\n(.*?)```", re.DOTALL) -INVALID_BACKTICKS = { - "'''", - '"""', - "\u00b4\u00b4\u00b4", # ACUTE ACCENT - "\u2018\u2018\u2018", # LEFT SINGLE QUOTATION MARK - "\u2019\u2019\u2019", # RIGHT SINGLE QUOTATION MARK - "\u2032\u2032\u2032", # PRIME - "\u201c\u201c\u201c", # LEFT DOUBLE QUOTATION MARK - "\u201d\u201d\u201d", # RIGHT DOUBLE QUOTATION MARK - "\u2033\u2033\u2033", # DOUBLE PRIME - "\u3003\u3003\u3003", # VERTICAL KANA REPEAT MARK UPPER HALF +TICKS = { + "`", + "'", + '"', + "\u00b4", # ACUTE ACCENT + "\u2018", # LEFT SINGLE QUOTATION MARK + "\u2019", # RIGHT SINGLE QUOTATION MARK + "\u2032", # PRIME + "\u201c", # LEFT DOUBLE QUOTATION MARK + "\u201d", # RIGHT DOUBLE QUOTATION MARK + "\u2033", # DOUBLE PRIME + "\u3003", # VERTICAL KANA REPEAT MARK UPPER HALF } +RE_CODE_BLOCK = re.compile( + fr""" + ( + ([{''.join(TICKS)}]) # Put all ticks into a character class within a group. + \2{{2}} # Match the previous group 2 more times to ensure it's the same char. + ) + ([^\W_]+\n)? # Optionally match a language specifier followed by a newline. + (.+?) # Match the actual code within the block. + \1 # Match the same 3 ticks used at the start of the block. + """, + re.DOTALL | re.VERBOSE +) class CodeBlockCog(Cog, name="Code Block"): @@ -266,7 +279,7 @@ class CodeBlockCog(Cog, name="Code Block"): @staticmethod def has_bad_ticks(message: discord.Message) -> bool: """Return True if `message` starts with 3 characters which look like but aren't '`'.""" - return message.content[:3] in INVALID_BACKTICKS + return message.content[:3] in TICKS @staticmethod def is_help_channel(channel: discord.TextChannel) -> bool: -- cgit v1.2.3 From f51b2cacdb8824b51517d10a479be9ec0629d066 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 May 2020 21:40:06 -0700 Subject: Code block: add function to find invalid code blocks * Create a `NamedTuple` representing a code block --- bot/cogs/codeblock/cog.py | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 292735f3f..6e87f9f15 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -2,7 +2,7 @@ import ast import logging import re import time -from typing import Optional, Tuple +from typing import NamedTuple, Optional, Sequence, Tuple import discord from discord import Embed, Message, RawMessageUpdateEvent @@ -16,8 +16,9 @@ log = logging.getLogger(__name__) RE_MARKDOWN = re.compile(r'([*_~`|>])') RE_CODE_BLOCK_LANGUAGE = re.compile(r"```(?:[^\W_]+)\n(.*?)```", re.DOTALL) +BACKTICK = "`" TICKS = { - "`", + BACKTICK, "'", '"', "\u00b4", # ACUTE ACCENT @@ -43,6 +44,14 @@ RE_CODE_BLOCK = re.compile( ) +class CodeBlock(NamedTuple): + """Represents a Markdown code block.""" + + content: str + language: str + tick: str + + class CodeBlockCog(Cog, name="Code Block"): """Detect improperly formatted code blocks and suggest proper formatting.""" @@ -217,6 +226,27 @@ class CodeBlockCog(Cog, name="Code Block"): else: log.trace("The code consists only of expressions, not sending instructions") + @staticmethod + def find_invalid_code_blocks(message: str) -> Sequence[CodeBlock]: + """ + Find and return all invalid Markdown code blocks in the `message`. + + An invalid code block is considered to be one which uses invalid back ticks. + + If the `message` contains at least one valid code block, return an empty sequence. This is + based on the assumption that if the user managed to get one code block right, they already + know how to fix the rest themselves. + """ + code_blocks = [] + for _, tick, language, content in RE_CODE_BLOCK.finditer(message): + if tick == BACKTICK: + return () + else: + code_block = CodeBlock(content, language.strip(), tick) + code_blocks.append(code_block) + + return code_blocks + def fix_indentation(self, msg: str) -> str: """Attempts to fix badly indented code.""" def unindent(code: str, skip_spaces: int = 0) -> str: -- cgit v1.2.3 From 1db3327239c65def7e3ddfcc54453cdadf240a90 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 May 2020 21:45:55 -0700 Subject: Code block: return code blocks with valid ticks but no lang Such code block will be useful down the road for sending information on including a language specified if the content successfully parses as valid Python. --- bot/cogs/codeblock/cog.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 6e87f9f15..970cbd63d 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -227,26 +227,23 @@ class CodeBlockCog(Cog, name="Code Block"): log.trace("The code consists only of expressions, not sending instructions") @staticmethod - def find_invalid_code_blocks(message: str) -> Sequence[CodeBlock]: + def find_code_blocks(message: str) -> Sequence[CodeBlock]: """ - Find and return all invalid Markdown code blocks in the `message`. + Find and return all Markdown code blocks in the `message`. - An invalid code block is considered to be one which uses invalid back ticks. - - If the `message` contains at least one valid code block, return an empty sequence. This is - based on the assumption that if the user managed to get one code block right, they already - know how to fix the rest themselves. + If the `message` contains at least one code block with valid ticks and a specified language, + return an empty sequence. This is based on the assumption that if the user managed to get + one code block right, they already know how to fix the rest themselves. """ code_blocks = [] for _, tick, language, content in RE_CODE_BLOCK.finditer(message): - if tick == BACKTICK: + language = language.strip() + if tick == BACKTICK and language: return () else: - code_block = CodeBlock(content, language.strip(), tick) + code_block = CodeBlock(content, language, tick) code_blocks.append(code_block) - return code_blocks - def fix_indentation(self, msg: str) -> str: """Attempts to fix badly indented code.""" def unindent(code: str, skip_spaces: int = 0) -> str: -- cgit v1.2.3 From 7169d2a6828babc3f670b9936a1e9111e1fe3948 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 4 May 2020 10:43:29 -0700 Subject: Code block: add function to truncate content The code was duplicated in each of the format message functions. The function also ensures content is truncated to 10 lines. Previously, code could have skipped truncating by being 100 lines long but under 204 characters in length. --- bot/cogs/codeblock/cog.py | 37 ++++++++++++++++--------------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 970cbd63d..c5704b730 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -153,16 +153,7 @@ class CodeBlockCog(Cog, name="Code Block"): else: content = content[0] - space_left = 204 - if len(content) >= space_left: - current_length = 0 - lines_walked = 0 - for line in content.splitlines(keepends=True): - if current_length + len(line) > space_left or lines_walked == 10: - break - current_length += len(line) - lines_walked += 1 - content = content[:current_length] + "#..." + content = self.truncate(content) content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) return ( @@ -190,22 +181,12 @@ class CodeBlockCog(Cog, name="Code Block"): # This check is to avoid all nodes being parsed as expressions. # (e.g. words over multiple lines) if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: - # Shorten the code to 10 lines and/or 204 characters. - space_left = 204 if content and repl_code: content = content[1] else: content = content[0] - if len(content) >= space_left: - current_length = 0 - lines_walked = 0 - for line in content.splitlines(keepends=True): - if current_length + len(line) > space_left or lines_walked == 10: - break - current_length += len(line) - lines_walked += 1 - content = content[:current_length] + "#..." + content = self.truncate(content) log.debug( f"{message.author} posted something that needed to be put inside python code " @@ -364,6 +345,20 @@ class CodeBlockCog(Cog, name="Code Block"): and not TokenRemover.find_token_in_message(message) ) + @staticmethod + def truncate(content: str, max_chars: int = 204, max_lines: int = 10) -> str: + """Return `content` truncated to be at most `max_chars` or `max_lines` in length.""" + current_length = 0 + lines_walked = 0 + + for line in content.splitlines(keepends=True): + if current_length + len(line) > max_chars or lines_walked == max_lines: + break + current_length += len(line) + lines_walked += 1 + + return content[:current_length] + "#..." + @Cog.listener() async def on_message(self, msg: Message) -> None: """ -- cgit v1.2.3 From 4c0c58252034a28debcee57aa0bb6b3a72e653d5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 5 May 2020 18:51:27 -0700 Subject: Code block: add function to check for valid Python code --- bot/cogs/codeblock/cog.py | 72 ++++++++++++++++++++++++++++------------------- 1 file changed, 43 insertions(+), 29 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index c5704b730..92bf43feb 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -173,39 +173,33 @@ class CodeBlockCog(Cog, name="Code Block"): return content, repl_code = content - # Attempts to parse the message into an AST node. - # Invalid Python code will raise a SyntaxError. - tree = ast.parse(content[0]) - # Multiple lines of single words could be interpreted as expressions. - # This check is to avoid all nodes being parsed as expressions. - # (e.g. words over multiple lines) - if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: - if content and repl_code: - content = content[1] - else: - content = content[0] + if not repl_code and not self.is_python_code(content[0]): + return - content = self.truncate(content) + if content and repl_code: + content = content[1] + else: + content = content[0] - log.debug( - f"{message.author} posted something that needed to be put inside python code " - f"blocks. Sending the user some instructions." - ) + content = self.truncate(content) - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) - return ( - "It looks like you're trying to paste code into this channel.\n\n" - "Discord has support for Markdown, which allows you to post code with full " - "syntax highlighting. Please use these whenever you paste code, as this " - "helps improve the legibility and makes it easier for us to help you.\n\n" - f"**To do this, use the following method:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) - else: - log.trace("The code consists only of expressions, not sending instructions") + log.debug( + f"{message.author} posted something that needed to be put inside python code " + f"blocks. Sending the user some instructions." + ) + + content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + return ( + "It looks like you're trying to paste code into this channel.\n\n" + "Discord has support for Markdown, which allows you to post code with full " + "syntax highlighting. Please use these whenever you paste code, as this " + "helps improve the legibility and makes it easier for us to help you.\n\n" + f"**To do this, use the following method:**\n" + f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" + f"```python\n{content}\n```" + ) @staticmethod def find_code_blocks(message: str) -> Sequence[CodeBlock]: @@ -305,6 +299,26 @@ class CodeBlockCog(Cog, name="Code Block"): """ return (time.time() - self.channel_cooldowns.get(channel.id, 0)) < 300 + @staticmethod + def is_python_code(content: str) -> bool: + """Return True if `content` is valid Python consisting of more than just expressions.""" + try: + # Attempt to parse the message into an AST node. + # Invalid Python code will raise a SyntaxError. + tree = ast.parse(content) + except SyntaxError: + log.trace("Code is not valid Python.") + return False + + # Multiple lines of single words could be interpreted as expressions. + # This check is to avoid all nodes being parsed as expressions. + # (e.g. words over multiple lines) + if not all(isinstance(node, ast.Expr) for node in tree.body): + return True + else: + log.trace("Code consists only of expressions.") + return False + def is_valid_channel(self, channel: discord.TextChannel) -> bool: """Return True if `channel` is a help channel, may be on cooldown, or is whitelisted.""" return ( -- cgit v1.2.3 From fb6017a8a00f5c54ea4532ff035abe8f34500f6f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 5 May 2020 19:43:41 -0700 Subject: Code block: exclude code blocks 3 lines or shorter --- bot/cogs/codeblock/cog.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 92bf43feb..64f9a4cbc 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -206,6 +206,8 @@ class CodeBlockCog(Cog, name="Code Block"): """ Find and return all Markdown code blocks in the `message`. + Code blocks with 3 or less lines are excluded. + If the `message` contains at least one code block with valid ticks and a specified language, return an empty sequence. This is based on the assumption that if the user managed to get one code block right, they already know how to fix the rest themselves. @@ -215,7 +217,7 @@ class CodeBlockCog(Cog, name="Code Block"): language = language.strip() if tick == BACKTICK and language: return () - else: + elif len(content.split("\n", 3)) > 3: code_block = CodeBlock(content, language, tick) code_blocks.append(code_block) -- cgit v1.2.3 From edc6c9a39c7681a72fca7ba053f5161f46eadfb9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 11:22:28 -0700 Subject: Code block: add function to check if REPL code exists The `repl_stripping` function was re-purposed. The plan going forward is to not show the user's code in the output so actual stripping is no longer necessary. --- bot/cogs/codeblock/cog.py | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 64f9a4cbc..25791801e 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -260,25 +260,18 @@ class CodeBlockCog(Cog, name="Code Block"): msg = f"{first_line}\n{unindent(code, 4)}" return msg - def repl_stripping(self, msg: str) -> Tuple[str, bool]: - """ - Strip msg in order to extract Python code out of REPL output. + @staticmethod + def is_repl_code(content: str, threshold: int = 3) -> bool: + """Return True if `content` has at least `threshold` number of Python REPL-like lines.""" + repl_lines = 0 + for line in content.splitlines(): + if line.startswith(">>> ") or line.startswith("... "): + repl_lines += 1 - Tries to strip out REPL Python code out of msg and returns the stripped msg. + if repl_lines == threshold: + return True - Returns True for the boolean if REPL code was found in the input msg. - """ - final = "" - for line in msg.splitlines(keepends=True): - if line.startswith(">>>") or line.startswith("..."): - final += line[4:] - log.trace(f"Formatted: \n\n{msg}\n\n to \n\n{final}\n\n") - if not final: - log.trace(f"Found no REPL code in \n\n{msg}\n\n") - return msg, False - else: - log.trace(f"Found REPL code in \n\n{msg}\n\n") - return final.rstrip(), True + return False @staticmethod def has_bad_ticks(message: discord.Message) -> bool: -- cgit v1.2.3 From 4d05e1de961d13389936896bba7704b8618be9c0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 11:43:09 -0700 Subject: Code block: remove obsolete functions The user's original code will not be displayed in the output so there is no longer a need for the functions which format their code. --- bot/cogs/codeblock/cog.py | 109 +--------------------------------------------- 1 file changed, 1 insertion(+), 108 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 25791801e..d0ffcab3f 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -2,7 +2,7 @@ import ast import logging import re import time -from typing import NamedTuple, Optional, Sequence, Tuple +from typing import NamedTuple, Optional, Sequence import discord from discord import Embed, Message, RawMessageUpdateEvent @@ -71,74 +71,6 @@ class CodeBlockCog(Cog, name="Code Block"): # Stores improperly formatted Python codeblock message ids and the corresponding bot message self.codeblock_message_ids = {} - def codeblock_stripping(self, msg: str, bad_ticks: bool) -> Optional[Tuple[Tuple[str, ...], str]]: - """ - Strip msg in order to find Python code. - - Tries to strip out Python code out of msg and returns the stripped block or - None if the block is a valid Python codeblock. - """ - if len(msg.split("\n", 3)) <= 3: - return None - - # Filtering valid Python codeblocks and exiting if a valid Python codeblock is found. - if RE_CODE_BLOCK_LANGUAGE.search(msg) and not bad_ticks: - log.trace("Code block already has valid syntax highlighting; no action taken") - return None - - else: - # Stripping backticks from every line of the message. - log.trace(f"Stripping backticks from message.\n\n{msg}\n\n") - content = "" - for line in msg.splitlines(keepends=True): - content += line.strip("`") - - content = content.strip() - - # Remove "Python" or "Py" from start of the message if it exists. - log.trace(f"Removing 'py' or 'python' from message.\n\n{content}\n\n") - pycode = False - if content.lower().startswith("python"): - content = content[6:] - pycode = True - elif content.lower().startswith("py"): - content = content[2:] - pycode = True - - if pycode: - content = content.splitlines(keepends=True) - - # Check if there might be code in the first line, and preserve it. - first_line = content[0] - if " " in content[0]: - first_space = first_line.index(" ") - content[0] = first_line[first_space:] - content = "".join(content) - - # If there's no code we can just get rid of the first line. - else: - content = "".join(content[1:]) - - # Strip it again to remove any leading whitespace. This is neccessary - # if the first line of the message looked like ```python - old = content.strip() - - # Strips REPL code out of the message if there is any. - content, repl_code = self.repl_stripping(old) - if old != content: - return (content, old), repl_code - - # Try to apply indentation fixes to the code. - content = self.fix_indentation(content) - - # Check if the code contains backticks, if it does ignore the message. - if "`" in content: - log.trace("Detected ` inside the code, won't reply") - return None - else: - log.trace(f"Returning message.\n\n{content}\n\n") - return (content,), repl_code - def format_bad_ticks_message(self, message: discord.Message) -> Optional[str]: """Return the guide message to output for bad code block ticks in `message`.""" ticks = message.content[:3] @@ -221,45 +153,6 @@ class CodeBlockCog(Cog, name="Code Block"): code_block = CodeBlock(content, language, tick) code_blocks.append(code_block) - def fix_indentation(self, msg: str) -> str: - """Attempts to fix badly indented code.""" - def unindent(code: str, skip_spaces: int = 0) -> str: - """Unindents all code down to the number of spaces given in skip_spaces.""" - final = "" - current = code[0] - leading_spaces = 0 - - # Get numbers of spaces before code in the first line. - while current == " ": - current = code[leading_spaces + 1] - leading_spaces += 1 - leading_spaces -= skip_spaces - - # If there are any, remove that number of spaces from every line. - if leading_spaces > 0: - for line in code.splitlines(keepends=True): - line = line[leading_spaces:] - final += line - return final - else: - return code - - # Apply fix for "all lines are overindented" case. - msg = unindent(msg) - - # If the first line does not end with a colon, we can be - # certain the next line will be on the same indentation level. - # - # If it does end with a colon, we will need to indent all successive - # lines one additional level. - first_line = msg.splitlines()[0] - code = "".join(msg.splitlines(keepends=True)[1:]) - if not first_line.endswith(":"): - msg = f"{first_line}\n{unindent(code)}" - else: - msg = f"{first_line}\n{unindent(code, 4)}" - return msg - @staticmethod def is_repl_code(content: str, threshold: int = 3) -> bool: """Return True if `content` has at least `threshold` number of Python REPL-like lines.""" -- cgit v1.2.3 From 89c54fbda81d790d09213fa3093772261d0c4947 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 14:59:04 -0700 Subject: Code block: move parsing functions to a separate module This reduces clutter in the cog. The cog should only have Discord- related functionality. --- bot/cogs/codeblock/cog.py | 128 +++--------------------------------------- bot/cogs/codeblock/parsing.py | 117 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 119 deletions(-) create mode 100644 bot/cogs/codeblock/parsing.py diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index d0ffcab3f..dad0cc9cc 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -1,8 +1,6 @@ -import ast import logging -import re import time -from typing import NamedTuple, Optional, Sequence +from typing import Optional import discord from discord import Embed, Message, RawMessageUpdateEvent @@ -11,46 +9,10 @@ from discord.ext.commands import Bot, Cog from bot.cogs.token_remover import TokenRemover from bot.constants import Categories, Channels, DEBUG_MODE from bot.utils.messages import wait_for_deletion +from . import parsing log = logging.getLogger(__name__) -RE_MARKDOWN = re.compile(r'([*_~`|>])') -RE_CODE_BLOCK_LANGUAGE = re.compile(r"```(?:[^\W_]+)\n(.*?)```", re.DOTALL) -BACKTICK = "`" -TICKS = { - BACKTICK, - "'", - '"', - "\u00b4", # ACUTE ACCENT - "\u2018", # LEFT SINGLE QUOTATION MARK - "\u2019", # RIGHT SINGLE QUOTATION MARK - "\u2032", # PRIME - "\u201c", # LEFT DOUBLE QUOTATION MARK - "\u201d", # RIGHT DOUBLE QUOTATION MARK - "\u2033", # DOUBLE PRIME - "\u3003", # VERTICAL KANA REPEAT MARK UPPER HALF -} -RE_CODE_BLOCK = re.compile( - fr""" - ( - ([{''.join(TICKS)}]) # Put all ticks into a character class within a group. - \2{{2}} # Match the previous group 2 more times to ensure it's the same char. - ) - ([^\W_]+\n)? # Optionally match a language specifier followed by a newline. - (.+?) # Match the actual code within the block. - \1 # Match the same 3 ticks used at the start of the block. - """, - re.DOTALL | re.VERBOSE -) - - -class CodeBlock(NamedTuple): - """Represents a Markdown code block.""" - - content: str - language: str - tick: str - class CodeBlockCog(Cog, name="Code Block"): """Detect improperly formatted code blocks and suggest proper formatting.""" @@ -85,8 +47,8 @@ class CodeBlockCog(Cog, name="Code Block"): else: content = content[0] - content = self.truncate(content) - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + content = parsing.truncate(content) + content_escaped_markdown = parsing.RE_MARKDOWN.sub(r'\\\1', content) return ( "It looks like you are trying to paste code into this channel.\n\n" @@ -106,7 +68,7 @@ class CodeBlockCog(Cog, name="Code Block"): content, repl_code = content - if not repl_code and not self.is_python_code(content[0]): + if not repl_code and not parsing.is_python_code(content[0]): return if content and repl_code: @@ -114,14 +76,14 @@ class CodeBlockCog(Cog, name="Code Block"): else: content = content[0] - content = self.truncate(content) + content = parsing.truncate(content) log.debug( f"{message.author} posted something that needed to be put inside python code " f"blocks. Sending the user some instructions." ) - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + content_escaped_markdown = parsing.RE_MARKDOWN.sub(r'\\\1', content) return ( "It looks like you're trying to paste code into this channel.\n\n" "Discord has support for Markdown, which allows you to post code with full " @@ -133,44 +95,6 @@ class CodeBlockCog(Cog, name="Code Block"): f"```python\n{content}\n```" ) - @staticmethod - def find_code_blocks(message: str) -> Sequence[CodeBlock]: - """ - Find and return all Markdown code blocks in the `message`. - - Code blocks with 3 or less lines are excluded. - - If the `message` contains at least one code block with valid ticks and a specified language, - return an empty sequence. This is based on the assumption that if the user managed to get - one code block right, they already know how to fix the rest themselves. - """ - code_blocks = [] - for _, tick, language, content in RE_CODE_BLOCK.finditer(message): - language = language.strip() - if tick == BACKTICK and language: - return () - elif len(content.split("\n", 3)) > 3: - code_block = CodeBlock(content, language, tick) - code_blocks.append(code_block) - - @staticmethod - def is_repl_code(content: str, threshold: int = 3) -> bool: - """Return True if `content` has at least `threshold` number of Python REPL-like lines.""" - repl_lines = 0 - for line in content.splitlines(): - if line.startswith(">>> ") or line.startswith("... "): - repl_lines += 1 - - if repl_lines == threshold: - return True - - return False - - @staticmethod - def has_bad_ticks(message: discord.Message) -> bool: - """Return True if `message` starts with 3 characters which look like but aren't '`'.""" - return message.content[:3] in TICKS - @staticmethod def is_help_channel(channel: discord.TextChannel) -> bool: """Return True if `channel` is in one of the help categories.""" @@ -187,26 +111,6 @@ class CodeBlockCog(Cog, name="Code Block"): """ return (time.time() - self.channel_cooldowns.get(channel.id, 0)) < 300 - @staticmethod - def is_python_code(content: str) -> bool: - """Return True if `content` is valid Python consisting of more than just expressions.""" - try: - # Attempt to parse the message into an AST node. - # Invalid Python code will raise a SyntaxError. - tree = ast.parse(content) - except SyntaxError: - log.trace("Code is not valid Python.") - return False - - # Multiple lines of single words could be interpreted as expressions. - # This check is to avoid all nodes being parsed as expressions. - # (e.g. words over multiple lines) - if not all(isinstance(node, ast.Expr) for node in tree.body): - return True - else: - log.trace("Code consists only of expressions.") - return False - def is_valid_channel(self, channel: discord.TextChannel) -> bool: """Return True if `channel` is a help channel, may be on cooldown, or is whitelisted.""" return ( @@ -247,20 +151,6 @@ class CodeBlockCog(Cog, name="Code Block"): and not TokenRemover.find_token_in_message(message) ) - @staticmethod - def truncate(content: str, max_chars: int = 204, max_lines: int = 10) -> str: - """Return `content` truncated to be at most `max_chars` or `max_lines` in length.""" - current_length = 0 - lines_walked = 0 - - for line in content.splitlines(keepends=True): - if current_length + len(line) > max_chars or lines_walked == max_lines: - break - current_length += len(line) - lines_walked += 1 - - return content[:current_length] + "#..." - @Cog.listener() async def on_message(self, msg: Message) -> None: """ @@ -277,7 +167,7 @@ class CodeBlockCog(Cog, name="Code Block"): return try: - if self.has_bad_ticks(msg): + if parsing.has_bad_ticks(msg): description = self.format_bad_ticks_message(msg) else: description = self.format_guide_message(msg) @@ -311,7 +201,7 @@ class CodeBlockCog(Cog, name="Code Block"): user_message = await channel.fetch_message(payload.message_id) # Checks to see if the user has corrected their codeblock. If it's fixed, has_fixed_codeblock will be None - has_fixed_codeblock = self.codeblock_stripping(payload.data.get("content"), self.has_bad_ticks(user_message)) + has_fixed_codeblock = self.codeblock_stripping(payload.data.get("content"), parsing.has_bad_ticks(user_message)) # If the message is fixed, delete the bot message and the entry from the id dictionary if has_fixed_codeblock is None: diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py new file mode 100644 index 000000000..7a096758b --- /dev/null +++ b/bot/cogs/codeblock/parsing.py @@ -0,0 +1,117 @@ +import ast +import logging +import re +from typing import NamedTuple, Sequence + +import discord + +log = logging.getLogger(__name__) + +RE_MARKDOWN = re.compile(r'([*_~`|>])') +RE_CODE_BLOCK_LANGUAGE = re.compile(r"```(?:[^\W_]+)\n(.*?)```", re.DOTALL) +BACKTICK = "`" +TICKS = { + BACKTICK, + "'", + '"', + "\u00b4", # ACUTE ACCENT + "\u2018", # LEFT SINGLE QUOTATION MARK + "\u2019", # RIGHT SINGLE QUOTATION MARK + "\u2032", # PRIME + "\u201c", # LEFT DOUBLE QUOTATION MARK + "\u201d", # RIGHT DOUBLE QUOTATION MARK + "\u2033", # DOUBLE PRIME + "\u3003", # VERTICAL KANA REPEAT MARK UPPER HALF +} +RE_CODE_BLOCK = re.compile( + fr""" + ( + ([{''.join(TICKS)}]) # Put all ticks into a character class within a group. + \2{{2}} # Match the previous group 2 more times to ensure it's the same char. + ) + ([^\W_]+\n)? # Optionally match a language specifier followed by a newline. + (.+?) # Match the actual code within the block. + \1 # Match the same 3 ticks used at the start of the block. + """, + re.DOTALL | re.VERBOSE +) + + +class CodeBlock(NamedTuple): + """Represents a Markdown code block.""" + + content: str + language: str + tick: str + + +def find_code_blocks(message: str) -> Sequence[CodeBlock]: + """ + Find and return all Markdown code blocks in the `message`. + + Code blocks with 3 or less lines are excluded. + + If the `message` contains at least one code block with valid ticks and a specified language, + return an empty sequence. This is based on the assumption that if the user managed to get + one code block right, they already know how to fix the rest themselves. + """ + code_blocks = [] + for _, tick, language, content in RE_CODE_BLOCK.finditer(message): + language = language.strip() + if tick == BACKTICK and language: + return () + elif len(content.split("\n", 3)) > 3: + code_block = CodeBlock(content, language, tick) + code_blocks.append(code_block) + + +def has_bad_ticks(message: discord.Message) -> bool: + """Return True if `message` starts with 3 characters which look like but aren't '`'.""" + return message.content[:3] in TICKS + + +def is_python_code(content: str) -> bool: + """Return True if `content` is valid Python consisting of more than just expressions.""" + try: + # Attempt to parse the message into an AST node. + # Invalid Python code will raise a SyntaxError. + tree = ast.parse(content) + except SyntaxError: + log.trace("Code is not valid Python.") + return False + + # Multiple lines of single words could be interpreted as expressions. + # This check is to avoid all nodes being parsed as expressions. + # (e.g. words over multiple lines) + if not all(isinstance(node, ast.Expr) for node in tree.body): + return True + else: + log.trace("Code consists only of expressions.") + return False + + +def is_repl_code(content: str, threshold: int = 3) -> bool: + """Return True if `content` has at least `threshold` number of Python REPL-like lines.""" + repl_lines = 0 + for line in content.splitlines(): + if line.startswith(">>> ") or line.startswith("... "): + repl_lines += 1 + + if repl_lines == threshold: + return True + + return False + + +def truncate(content: str, max_chars: int = 204, max_lines: int = 10) -> str: + """Return `content` truncated to be at most `max_chars` or `max_lines` in length.""" + current_length = 0 + lines_walked = 0 + + for line in content.splitlines(keepends=True): + if current_length + len(line) > max_chars or lines_walked == max_lines: + break + current_length += len(line) + lines_walked += 1 + + return content[:current_length] + "#..." -- cgit v1.2.3 From 2a7dcccf7a6b352e3f43b4248d00d9ec15af243e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 13:30:51 -0700 Subject: Code block: rework the instruction formatting functions A new module, `instructions`, was created to house the functions. 4 ways in which code blocks can be incorrect are considered: 1. The code is not within a code block at all 2. Incorrect characters are used for back ticks 3. A language is not specified 4. A language is specified incorrectly Splitting it up into these 4 cases allows for more specific and relevant instructions to be shown to users. If a message has both incorrect back ticks and an issue with the language specifier, the instructions for fixing both issues are combined. The instructions show a generic code example rather than using the original code from the message. This circumvents any ambiguities when parsing their message and trying to fix it. The escaped code block also failed to preserve indentation. This was a problem because some users would copy it anyway and end up with poorly formatted code. By using a simple example that doesn't rely on indentation, it makes it clear the example is not meant to be copied. Finally, the new examples are shorter and thus make the embed not as giant. --- bot/cogs/codeblock/cog.py | 63 --------------------- bot/cogs/codeblock/instructions.py | 113 +++++++++++++++++++++++++++++++++++++ bot/cogs/codeblock/parsing.py | 2 - 3 files changed, 113 insertions(+), 65 deletions(-) create mode 100644 bot/cogs/codeblock/instructions.py diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index dad0cc9cc..efc22c8a5 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -1,6 +1,5 @@ import logging import time -from typing import Optional import discord from discord import Embed, Message, RawMessageUpdateEvent @@ -33,68 +32,6 @@ class CodeBlockCog(Cog, name="Code Block"): # Stores improperly formatted Python codeblock message ids and the corresponding bot message self.codeblock_message_ids = {} - def format_bad_ticks_message(self, message: discord.Message) -> Optional[str]: - """Return the guide message to output for bad code block ticks in `message`.""" - ticks = message.content[:3] - content = self.codeblock_stripping(f"```{message.content[3:-3]}```", True) - if content is None: - return - - content, repl_code = content - - if len(content) == 2: - content = content[1] - else: - content = content[0] - - content = parsing.truncate(content) - content_escaped_markdown = parsing.RE_MARKDOWN.sub(r'\\\1', content) - - return ( - "It looks like you are trying to paste code into this channel.\n\n" - "You seem to be using the wrong symbols to indicate where the codeblock should start. " - f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n" - "**Here is an example of how it should look:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) - - def format_guide_message(self, message: discord.Message) -> Optional[str]: - """Return the guide message to output for a poorly formatted code block in `message`.""" - content = self.codeblock_stripping(message.content, False) - if content is None: - return - - content, repl_code = content - - if not repl_code and not parsing.is_python_code(content[0]): - return - - if content and repl_code: - content = content[1] - else: - content = content[0] - - content = parsing.truncate(content) - - log.debug( - f"{message.author} posted something that needed to be put inside python code " - f"blocks. Sending the user some instructions." - ) - - content_escaped_markdown = parsing.RE_MARKDOWN.sub(r'\\\1', content) - return ( - "It looks like you're trying to paste code into this channel.\n\n" - "Discord has support for Markdown, which allows you to post code with full " - "syntax highlighting. Please use these whenever you paste code, as this " - "helps improve the legibility and makes it easier for us to help you.\n\n" - f"**To do this, use the following method:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) - @staticmethod def is_help_channel(channel: discord.TextChannel) -> bool: """Return True if `channel` is in one of the help categories.""" diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py new file mode 100644 index 000000000..0bcd2eda8 --- /dev/null +++ b/bot/cogs/codeblock/instructions.py @@ -0,0 +1,113 @@ +import logging +from typing import Optional + +from . import parsing + +log = logging.getLogger(__name__) + +PY_LANG_CODES = ("python", "py") +EXAMPLE_PY = f"python\nprint('Hello, world!')" # Make sure to escape any Markdown symbols here. +EXAMPLE_CODE_BLOCKS = ( + "\\`\\`\\`{content}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" + "```{content}```" +) + + +def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: + """Return instructions on using the correct ticks for `code_block`.""" + valid_ticks = f"\\{parsing.BACKTICK}" * 3 + + # The space at the end is important here because something may be appended! + instructions = ( + "It looks like you are trying to paste code into this channel.\n\n" + "You seem to be using the wrong symbols to indicate where the code block should start. " + f"The correct symbols would be {valid_ticks}, not `{code_block.tick * 3}`. " + ) + + # Check if the code has an issue with the language specifier. + addition_msg = get_bad_lang_message(code_block.content) + if not addition_msg: + addition_msg = get_no_lang_message(code_block.content) + + # Combine the back ticks message with the language specifier message. The latter will + # already have an example code block. + if addition_msg: + # The first line has a double line break which is not desirable when appending the msg. + addition_msg = addition_msg.replace("\n\n", "\n", 1) + + # Make the first character of the addition lower case. + instructions += "Furthermore, " + addition_msg[0].lower() + addition_msg[1:] + else: + # Determine the example code to put in the code block based on the language specifier. + if code_block.language.lower() in PY_LANG_CODES: + content = EXAMPLE_PY + elif code_block.language: + # It's not feasible to determine what would be a valid example for other languages. + content = f"{code_block.language}\n..." + else: + content = "Hello, world!" + + example_blocks = EXAMPLE_CODE_BLOCKS.format(content) + instructions += f"\n\n**Here is an example of how it should look:**\n{example_blocks}" + + return instructions + + +def get_no_ticks_message(content: str) -> Optional[str]: + """If `content` is Python/REPL code, return instructions on using code blocks.""" + if parsing.is_repl_code(content) or parsing.is_python_code(content): + example_blocks = EXAMPLE_CODE_BLOCKS.format(EXAMPLE_PY) + return ( + "It looks like you're trying to paste code into this channel.\n\n" + "Discord has support for Markdown, which allows you to post code with full " + "syntax highlighting. Please use these whenever you paste code, as this " + "helps improve the legibility and makes it easier for us to help you.\n\n" + f"**To do this, use the following method:**\n{example_blocks}" + ) + + +def get_bad_lang_message(content: str) -> Optional[str]: + """ + Return instructions on fixing the Python language specifier for a code block. + + If `content` doesn't start with "python" or "py" as the language specifier, return None. + """ + stripped = content.lstrip().lower() + lang = next((lang for lang in PY_LANG_CODES if stripped.startswith(lang)), None) + + if lang: + # Note that get_bad_ticks_message expects the first line to have an extra newline. + lines = ["It looks like you incorrectly specified a language for your code block.\n"] + + if content.startswith(" "): + lines.append(f"Make sure there are no spaces between the back ticks and `{lang}`.") + + if stripped[len(lang)] != "\n": + lines.append( + f"Make sure you put your code on a new line following `{lang}`. " + f"There must not be any spaces after `{lang}`." + ) + + example_blocks = EXAMPLE_CODE_BLOCKS.format(EXAMPLE_PY) + lines.append(f"\n**Here is an example of how it should look:**\n{example_blocks}") + + return "\n".join(lines) + + +def get_no_lang_message(content: str) -> Optional[str]: + """ + Return instructions on specifying a language for a code block. + + If `content` is not valid Python or Python REPL code, return None. + """ + if parsing.is_repl_code(content) or parsing.is_python_code(content): + example_blocks = EXAMPLE_CODE_BLOCKS.format(EXAMPLE_PY) + + # Note that get_bad_ticks_message expects the first line to have an extra newline. + return ( + "It looks like you pasted Python code without syntax highlighting.\n\n" + "Please use syntax highlighting to improve the legibility of your code and make" + "it easier for us to help you.\n\n" + f"**To do this, use the following method:**\n{example_blocks}" + ) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 7a096758b..d541441e0 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -7,8 +7,6 @@ import discord log = logging.getLogger(__name__) -RE_MARKDOWN = re.compile(r'([*_~`|>])') -RE_CODE_BLOCK_LANGUAGE = re.compile(r"```(?:[^\W_]+)\n(.*?)```", re.DOTALL) BACKTICK = "`" TICKS = { BACKTICK, -- cgit v1.2.3 From 59dfd276adabeb8ba643a0b22128af7d765d3210 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 13:33:09 -0700 Subject: Code block: remove truncate function No longer used anywhere. --- bot/cogs/codeblock/parsing.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index d541441e0..bb71aaaaf 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -99,17 +99,3 @@ def is_repl_code(content: str, threshold: int = 3) -> bool: return True return False - - -def truncate(content: str, max_chars: int = 204, max_lines: int = 10) -> str: - """Return `content` truncated to be at most `max_chars` or `max_lines` in length.""" - current_length = 0 - lines_walked = 0 - - for line in content.splitlines(keepends=True): - if current_length + len(line) > max_chars or lines_walked == max_lines: - break - current_length += len(line) - lines_walked += 1 - - return content[:current_length] + "#..." -- cgit v1.2.3 From a61d0564b46ee4f2cb295317cdad6a47bfd88e13 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 13:41:37 -0700 Subject: Code block: use new formatting functions in on_message --- bot/cogs/codeblock/cog.py | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index efc22c8a5..959fc138e 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -8,7 +8,7 @@ from discord.ext.commands import Bot, Cog from bot.cogs.token_remover import TokenRemover from bot.constants import Categories, Channels, DEBUG_MODE from bot.utils.messages import wait_for_deletion -from . import parsing +from . import instructions, parsing log = logging.getLogger(__name__) @@ -90,12 +90,7 @@ class CodeBlockCog(Cog, name="Code Block"): @Cog.listener() async def on_message(self, msg: Message) -> None: - """ - Detect poorly formatted Python code in new messages. - - If poorly formatted code is detected, send the user a helpful message explaining how to do - properly formatted Python syntax highlighting codeblocks. - """ + """Detect incorrect Markdown code blocks in `msg` and send instructions to fix them.""" if not self.should_parse(msg): return @@ -103,17 +98,25 @@ class CodeBlockCog(Cog, name="Code Block"): if self.is_on_cooldown(msg.channel) and not DEBUG_MODE: return - try: - if parsing.has_bad_ticks(msg): - description = self.format_bad_ticks_message(msg) + blocks = parsing.find_code_blocks(msg.content) + if not blocks: + # No code blocks found in the message. + description = instructions.get_no_ticks_message(msg.content) + else: + # Get the first code block with invalid ticks. + block = next((block for block in blocks if block.tick != parsing.BACKTICK), None) + + if block: + # A code block exists but has invalid ticks. + description = instructions.get_bad_ticks_message(block) else: - description = self.format_guide_message(msg) - except SyntaxError: - log.trace( - f"SyntaxError while parsing code block sent by {msg.author}; " - f"code posted probably just wasn't Python:\n\n{msg.content}\n\n" - ) - return + # Only other possibility is a block with valid ticks but a missing language. + block = blocks[0] + + # Check for a bad language first to avoid parsing content into an AST. + description = instructions.get_bad_lang_message(block.content) + if not description: + description = instructions.get_no_lang_message(block.content) if description: await self.send_guide_embed(msg, description) -- cgit v1.2.3 From 3fe6c4aac91b691de9b60c9fd89d23539a18b9a4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 13:53:11 -0700 Subject: Code block: use find_code_blocks to check if an edited msg was fixed * Remove has_bad_ticks - it's obsolete --- bot/cogs/codeblock/cog.py | 17 ++++++++--------- bot/cogs/codeblock/parsing.py | 7 ------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 959fc138e..19ddb8c73 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -125,7 +125,7 @@ class CodeBlockCog(Cog, name="Code Block"): @Cog.listener() async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: - """Check to see if an edited message (previously called out) still contains poorly formatted code.""" + """Delete the instructions message if an edited message had its code blocks fixed.""" if ( # Checks to see if the message was called out by the bot payload.message_id not in self.codeblock_message_ids @@ -136,16 +136,15 @@ class CodeBlockCog(Cog, name="Code Block"): ): return - # Retrieve channel and message objects for use later - channel = self.bot.get_channel(int(payload.data.get("channel_id"))) - user_message = await channel.fetch_message(payload.message_id) + # Parse the message to see if the code blocks have been fixed. + code_blocks = parsing.find_code_blocks(payload.data.get("content")) - # Checks to see if the user has corrected their codeblock. If it's fixed, has_fixed_codeblock will be None - has_fixed_codeblock = self.codeblock_stripping(payload.data.get("content"), parsing.has_bad_ticks(user_message)) + # If the message is fixed, delete the bot message and the entry from the id dictionary. + if not code_blocks: + log.trace("User's incorrect code block has been fixed. Removing bot formatting message.") - # If the message is fixed, delete the bot message and the entry from the id dictionary - if has_fixed_codeblock is None: + channel = self.bot.get_channel(int(payload.data.get("channel_id"))) bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) + await bot_message.delete() del self.codeblock_message_ids[payload.message_id] - log.trace("User's incorrect code block has been fixed. Removing bot formatting message.") diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index bb71aaaaf..88a5c7b7a 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -3,8 +3,6 @@ import logging import re from typing import NamedTuple, Sequence -import discord - log = logging.getLogger(__name__) BACKTICK = "`" @@ -63,11 +61,6 @@ def find_code_blocks(message: str) -> Sequence[CodeBlock]: code_blocks.append(code_block) -def has_bad_ticks(message: discord.Message) -> bool: - """Return True if `message` starts with 3 characters which look like but aren't '`'.""" - return message.content[:3] in TICKS - - def is_python_code(content: str) -> bool: """Return True if `content` is valid Python consisting of more than just expressions.""" try: -- cgit v1.2.3 From 8c34a279175ee1193cb3a4df625f81758c258da5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 14:08:37 -0700 Subject: Code block: load the extension --- bot/__main__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/__main__.py b/bot/__main__.py index 4e0d4a111..8bbb7fbb3 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -51,6 +51,7 @@ bot.load_extension("bot.cogs.verification") # Feature cogs bot.load_extension("bot.cogs.alias") +bot.load_extension("bot.cogs.codeblock") bot.load_extension("bot.cogs.defcon") bot.load_extension("bot.cogs.duck_pond") bot.load_extension("bot.cogs.eval") -- cgit v1.2.3 From 8782d3018e5cbc4ef04e4b8e74b90025de3004b3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 14:18:40 -0700 Subject: Code block: fix find_code_blocks iteration and missing return * Add named capture groups to the regex --- bot/cogs/codeblock/parsing.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 88a5c7b7a..9adb4e0ab 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -21,13 +21,13 @@ TICKS = { } RE_CODE_BLOCK = re.compile( fr""" - ( - ([{''.join(TICKS)}]) # Put all ticks into a character class within a group. - \2{{2}} # Match the previous group 2 more times to ensure it's the same char. + (?P + (?P[{''.join(TICKS)}]) # Put all ticks into a character class within a group. + \2{{2}} # Match previous group 2 more times to ensure the same char. ) - ([^\W_]+\n)? # Optionally match a language specifier followed by a newline. - (.+?) # Match the actual code within the block. - \1 # Match the same 3 ticks used at the start of the block. + (?P[^\W_]+\n)? # Optionally match a language specifier followed by a newline. + (?P.+?) # Match the actual code within the block. + \1 # Match the same 3 ticks used at the start of the block. """, re.DOTALL | re.VERBOSE ) @@ -52,14 +52,19 @@ def find_code_blocks(message: str) -> Sequence[CodeBlock]: one code block right, they already know how to fix the rest themselves. """ code_blocks = [] - for _, tick, language, content in RE_CODE_BLOCK.finditer(message): - language = language.strip() - if tick == BACKTICK and language: + for match in RE_CODE_BLOCK.finditer(message): + # Used to ensure non-matched groups have an empty string as the default value. + groups = match.groupdict("") + language = groups["lang"].strip() # Strip the newline cause it's included in the group. + + if groups["tick"] == BACKTICK and language: return () - elif len(content.split("\n", 3)) > 3: - code_block = CodeBlock(content, language, tick) + elif len(groups["code"].split("\n", 3)) > 3: + code_block = CodeBlock(groups["code"], language, groups["tick"]) code_blocks.append(code_block) + return code_blocks + def is_python_code(content: str) -> bool: """Return True if `content` is valid Python consisting of more than just expressions.""" -- cgit v1.2.3 From 38d07cacadfb34fb4caf536eb792d36a066e3629 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 14:21:23 -0700 Subject: Code block: fix formatting of example code blocks --- bot/cogs/codeblock/instructions.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 0bcd2eda8..6d267239d 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -48,7 +48,7 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: else: content = "Hello, world!" - example_blocks = EXAMPLE_CODE_BLOCKS.format(content) + example_blocks = EXAMPLE_CODE_BLOCKS.format(content=content) instructions += f"\n\n**Here is an example of how it should look:**\n{example_blocks}" return instructions @@ -57,7 +57,7 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: def get_no_ticks_message(content: str) -> Optional[str]: """If `content` is Python/REPL code, return instructions on using code blocks.""" if parsing.is_repl_code(content) or parsing.is_python_code(content): - example_blocks = EXAMPLE_CODE_BLOCKS.format(EXAMPLE_PY) + example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY) return ( "It looks like you're trying to paste code into this channel.\n\n" "Discord has support for Markdown, which allows you to post code with full " @@ -89,7 +89,7 @@ def get_bad_lang_message(content: str) -> Optional[str]: f"There must not be any spaces after `{lang}`." ) - example_blocks = EXAMPLE_CODE_BLOCKS.format(EXAMPLE_PY) + example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY) lines.append(f"\n**Here is an example of how it should look:**\n{example_blocks}") return "\n".join(lines) @@ -102,7 +102,7 @@ def get_no_lang_message(content: str) -> Optional[str]: If `content` is not valid Python or Python REPL code, return None. """ if parsing.is_repl_code(content) or parsing.is_python_code(content): - example_blocks = EXAMPLE_CODE_BLOCKS.format(EXAMPLE_PY) + example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY) # Note that get_bad_ticks_message expects the first line to have an extra newline. return ( -- cgit v1.2.3 From 29d4962518e1b0aa1664b676c33b631e634ad9ea Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 14:21:44 -0700 Subject: Code block: fix missing space between words in message --- bot/cogs/codeblock/instructions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 6d267239d..0f05e68b1 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -107,7 +107,7 @@ def get_no_lang_message(content: str) -> Optional[str]: # Note that get_bad_ticks_message expects the first line to have an extra newline. return ( "It looks like you pasted Python code without syntax highlighting.\n\n" - "Please use syntax highlighting to improve the legibility of your code and make" + "Please use syntax highlighting to improve the legibility of your code and make " "it easier for us to help you.\n\n" f"**To do this, use the following method:**\n{example_blocks}" ) -- cgit v1.2.3 From 30967602e2faabb6654d30c1fc7e1c4f4e3d2919 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 14:24:49 -0700 Subject: Code block: fix formatting of the additional message The newlines should be replaced with a space rather than with 1 newline. To separate the two issues, a double newline is prepended to the entire additional message. --- bot/cogs/codeblock/instructions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 0f05e68b1..dec5af874 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -34,10 +34,10 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: # already have an example code block. if addition_msg: # The first line has a double line break which is not desirable when appending the msg. - addition_msg = addition_msg.replace("\n\n", "\n", 1) + addition_msg = addition_msg.replace("\n\n", " ", 1) # Make the first character of the addition lower case. - instructions += "Furthermore, " + addition_msg[0].lower() + addition_msg[1:] + instructions += "\n\nFurthermore, " + addition_msg[0].lower() + addition_msg[1:] else: # Determine the example code to put in the code block based on the language specifier. if code_block.language.lower() in PY_LANG_CODES: -- cgit v1.2.3 From 0eca42cee34672fd59b82d0b36a70627a13d6354 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 14:30:37 -0700 Subject: Code block: use same lang specifier as the user for the py example Keeping examples consistent will hopefully make things clearer to the user. --- bot/cogs/codeblock/instructions.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index dec5af874..9de418765 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -6,7 +6,7 @@ from . import parsing log = logging.getLogger(__name__) PY_LANG_CODES = ("python", "py") -EXAMPLE_PY = f"python\nprint('Hello, world!')" # Make sure to escape any Markdown symbols here. +EXAMPLE_PY = "{lang}\nprint('Hello, world!')" # Make sure to escape any Markdown symbols here. EXAMPLE_CODE_BLOCKS = ( "\\`\\`\\`{content}\n\\`\\`\\`\n\n" "**This will result in the following:**\n" @@ -41,7 +41,7 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: else: # Determine the example code to put in the code block based on the language specifier. if code_block.language.lower() in PY_LANG_CODES: - content = EXAMPLE_PY + content = EXAMPLE_PY.format(lang=code_block.language) elif code_block.language: # It's not feasible to determine what would be a valid example for other languages. content = f"{code_block.language}\n..." @@ -57,7 +57,7 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: def get_no_ticks_message(content: str) -> Optional[str]: """If `content` is Python/REPL code, return instructions on using code blocks.""" if parsing.is_repl_code(content) or parsing.is_python_code(content): - example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY) + example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY.format(lang="python")) return ( "It looks like you're trying to paste code into this channel.\n\n" "Discord has support for Markdown, which allows you to post code with full " @@ -89,7 +89,7 @@ def get_bad_lang_message(content: str) -> Optional[str]: f"There must not be any spaces after `{lang}`." ) - example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY) + example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY.format(lang=lang)) lines.append(f"\n**Here is an example of how it should look:**\n{example_blocks}") return "\n".join(lines) @@ -102,7 +102,7 @@ def get_no_lang_message(content: str) -> Optional[str]: If `content` is not valid Python or Python REPL code, return None. """ if parsing.is_repl_code(content) or parsing.is_python_code(content): - example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY) + example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY.format(lang="python")) # Note that get_bad_ticks_message expects the first line to have an extra newline. return ( -- cgit v1.2.3 From 6ec3c712113d350cc027a503ebb0951cfa2fd65a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 22:39:31 -0700 Subject: Code block: add trace logging --- bot/cogs/codeblock/cog.py | 17 +++++++++++++---- bot/cogs/codeblock/instructions.py | 26 ++++++++++++++++++++++++-- bot/cogs/codeblock/parsing.py | 11 +++++++++++ 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 19ddb8c73..e4b87938d 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -35,6 +35,7 @@ class CodeBlockCog(Cog, name="Code Block"): @staticmethod def is_help_channel(channel: discord.TextChannel) -> bool: """Return True if `channel` is in one of the help categories.""" + log.trace(f"Checking if #{channel} is a help channel.") return ( getattr(channel, "category", None) and channel.category.id in (Categories.help_available, Categories.help_in_use) @@ -46,10 +47,12 @@ class CodeBlockCog(Cog, name="Code Block"): Note: only channels in the `channel_cooldowns` have cooldowns enabled. """ + log.trace(f"Checking if #{channel} is on cooldown.") return (time.time() - self.channel_cooldowns.get(channel.id, 0)) < 300 def is_valid_channel(self, channel: discord.TextChannel) -> bool: """Return True if `channel` is a help channel, may be on cooldown, or is whitelisted.""" + log.trace(f"Checking if #{channel} qualifies for code block detection.") return ( self.is_help_channel(channel) or channel.id in self.channel_cooldowns @@ -62,6 +65,8 @@ class CodeBlockCog(Cog, name="Code Block"): The embed will be deleted automatically after 5 minutes. """ + log.trace("Sending an embed with code block formatting instructions.") + embed = Embed(description=description) bot_message = await message.channel.send(f"Hey {message.author.mention}!", embed=embed) self.codeblock_message_ids[message.id] = bot_message.id @@ -92,25 +97,27 @@ class CodeBlockCog(Cog, name="Code Block"): async def on_message(self, msg: Message) -> None: """Detect incorrect Markdown code blocks in `msg` and send instructions to fix them.""" if not self.should_parse(msg): + log.trace(f"Skipping code block detection of {msg.id}: message doesn't qualify.") return # When debugging, ignore cooldowns. if self.is_on_cooldown(msg.channel) and not DEBUG_MODE: + log.trace(f"Skipping code block detection of {msg.id}: #{msg.channel} is on cooldown.") return blocks = parsing.find_code_blocks(msg.content) if not blocks: - # No code blocks found in the message. + log.trace(f"No code blocks were found in message {msg.id}.") description = instructions.get_no_ticks_message(msg.content) else: - # Get the first code block with invalid ticks. + log.trace("Searching results for a code block with invalid ticks.") block = next((block for block in blocks if block.tick != parsing.BACKTICK), None) if block: - # A code block exists but has invalid ticks. + log.trace(f"A code block exists in {msg.id} but has invalid ticks.") description = instructions.get_bad_ticks_message(block) else: - # Only other possibility is a block with valid ticks but a missing language. + log.trace(f"A code block exists in {msg.id} but is missing a language.") block = blocks[0] # Check for a bad language first to avoid parsing content into an AST. @@ -121,6 +128,7 @@ class CodeBlockCog(Cog, name="Code Block"): if description: await self.send_guide_embed(msg, description) if msg.channel.id not in self.channel_whitelist: + log.trace(f"Adding #{msg.channel} to the channel cooldowns.") self.channel_cooldowns[msg.channel.id] = time.time() @Cog.listener() @@ -134,6 +142,7 @@ class CodeBlockCog(Cog, name="Code Block"): # Makes sure there's a channel id in the message payload or payload.data.get("channel_id") is None ): + log.trace("Message edit does not qualify for code block detection.") return # Parse the message to see if the code blocks have been fixed. diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 9de418765..28242ce75 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -5,7 +5,7 @@ from . import parsing log = logging.getLogger(__name__) -PY_LANG_CODES = ("python", "py") +PY_LANG_CODES = ("python", "py") # Order is important; "py" is second cause it's a subset. EXAMPLE_PY = "{lang}\nprint('Hello, world!')" # Make sure to escape any Markdown symbols here. EXAMPLE_CODE_BLOCKS = ( "\\`\\`\\`{content}\n\\`\\`\\`\n\n" @@ -16,6 +16,7 @@ EXAMPLE_CODE_BLOCKS = ( def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: """Return instructions on using the correct ticks for `code_block`.""" + log.trace("Creating instructions for incorrect code block ticks.") valid_ticks = f"\\{parsing.BACKTICK}" * 3 # The space at the end is important here because something may be appended! @@ -25,7 +26,7 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: f"The correct symbols would be {valid_ticks}, not `{code_block.tick * 3}`. " ) - # Check if the code has an issue with the language specifier. + log.trace("Check if the bad ticks code block also has issues with the language specifier.") addition_msg = get_bad_lang_message(code_block.content) if not addition_msg: addition_msg = get_no_lang_message(code_block.content) @@ -33,19 +34,26 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: # Combine the back ticks message with the language specifier message. The latter will # already have an example code block. if addition_msg: + log.trace("Language specifier issue found; appending additional instructions.") + # The first line has a double line break which is not desirable when appending the msg. addition_msg = addition_msg.replace("\n\n", " ", 1) # Make the first character of the addition lower case. instructions += "\n\nFurthermore, " + addition_msg[0].lower() + addition_msg[1:] else: + log.trace("No issues with the language specifier found.") + # Determine the example code to put in the code block based on the language specifier. if code_block.language.lower() in PY_LANG_CODES: + log.trace(f"Code block has a Python language specifier `{code_block.language}`.") content = EXAMPLE_PY.format(lang=code_block.language) elif code_block.language: + log.trace(f"Code block has a foreign language specifier `{code_block.language}`.") # It's not feasible to determine what would be a valid example for other languages. content = f"{code_block.language}\n..." else: + log.trace("Code block has no language specifier (and the code isn't valid Python).") content = "Hello, world!" example_blocks = EXAMPLE_CODE_BLOCKS.format(content=content) @@ -56,6 +64,8 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: def get_no_ticks_message(content: str) -> Optional[str]: """If `content` is Python/REPL code, return instructions on using code blocks.""" + log.trace("Creating instructions for a missing code block.") + if parsing.is_repl_code(content) or parsing.is_python_code(content): example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY.format(lang="python")) return ( @@ -65,6 +75,8 @@ def get_no_ticks_message(content: str) -> Optional[str]: "helps improve the legibility and makes it easier for us to help you.\n\n" f"**To do this, use the following method:**\n{example_blocks}" ) + else: + log.trace("Aborting missing code block instructions: content is not Python code.") def get_bad_lang_message(content: str) -> Optional[str]: @@ -73,6 +85,8 @@ def get_bad_lang_message(content: str) -> Optional[str]: If `content` doesn't start with "python" or "py" as the language specifier, return None. """ + log.trace("Creating instructions for a poorly specified language.") + stripped = content.lstrip().lower() lang = next((lang for lang in PY_LANG_CODES if stripped.startswith(lang)), None) @@ -81,9 +95,11 @@ def get_bad_lang_message(content: str) -> Optional[str]: lines = ["It looks like you incorrectly specified a language for your code block.\n"] if content.startswith(" "): + log.trace("Language specifier was preceded by a space.") lines.append(f"Make sure there are no spaces between the back ticks and `{lang}`.") if stripped[len(lang)] != "\n": + log.trace("Language specifier was not followed by a newline.") lines.append( f"Make sure you put your code on a new line following `{lang}`. " f"There must not be any spaces after `{lang}`." @@ -93,6 +109,8 @@ def get_bad_lang_message(content: str) -> Optional[str]: lines.append(f"\n**Here is an example of how it should look:**\n{example_blocks}") return "\n".join(lines) + else: + log.trace("Aborting bad language instructions: language specified isn't Python.") def get_no_lang_message(content: str) -> Optional[str]: @@ -101,6 +119,8 @@ def get_no_lang_message(content: str) -> Optional[str]: If `content` is not valid Python or Python REPL code, return None. """ + log.trace("Creating instructions for a missing language.") + if parsing.is_repl_code(content) or parsing.is_python_code(content): example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY.format(lang="python")) @@ -111,3 +131,5 @@ def get_no_lang_message(content: str) -> Optional[str]: "it easier for us to help you.\n\n" f"**To do this, use the following method:**\n{example_blocks}" ) + else: + log.trace("Aborting missing language instructions: content is not Python code.") diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 9adb4e0ab..7409653d7 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -51,6 +51,8 @@ def find_code_blocks(message: str) -> Sequence[CodeBlock]: return an empty sequence. This is based on the assumption that if the user managed to get one code block right, they already know how to fix the rest themselves. """ + log.trace("Finding all code blocks in a message.") + code_blocks = [] for match in RE_CODE_BLOCK.finditer(message): # Used to ensure non-matched groups have an empty string as the default value. @@ -58,16 +60,20 @@ def find_code_blocks(message: str) -> Sequence[CodeBlock]: language = groups["lang"].strip() # Strip the newline cause it's included in the group. if groups["tick"] == BACKTICK and language: + log.trace("Message has a valid code block with a language; returning empty tuple.") return () elif len(groups["code"].split("\n", 3)) > 3: code_block = CodeBlock(groups["code"], language, groups["tick"]) code_blocks.append(code_block) + else: + log.trace("Skipped a code block shorter than 4 lines.") return code_blocks def is_python_code(content: str) -> bool: """Return True if `content` is valid Python consisting of more than just expressions.""" + log.trace("Checking if content is Python code.") try: # Attempt to parse the message into an AST node. # Invalid Python code will raise a SyntaxError. @@ -80,6 +86,7 @@ def is_python_code(content: str) -> bool: # This check is to avoid all nodes being parsed as expressions. # (e.g. words over multiple lines) if not all(isinstance(node, ast.Expr) for node in tree.body): + log.trace("Code is valid python.") return True else: log.trace("Code consists only of expressions.") @@ -88,12 +95,16 @@ def is_python_code(content: str) -> bool: def is_repl_code(content: str, threshold: int = 3) -> bool: """Return True if `content` has at least `threshold` number of Python REPL-like lines.""" + log.trace(f"Checking if content is Python REPL code using a threshold of {threshold}.") + repl_lines = 0 for line in content.splitlines(): if line.startswith(">>> ") or line.startswith("... "): repl_lines += 1 if repl_lines == threshold: + log.trace("Content is Python REPL code.") return True + log.trace("Content is not Python REPL code.") return False -- cgit v1.2.3 From 808fe261cb0163fe5759da36e36418fc392cb846 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 22:46:07 -0700 Subject: Code block: fix valid code block being parsed as a missing block `find_code_blocks` was returning an empty tuple if there was at least one valid code block. However, the caller could not distinguish between that case and simply no code blocks being found. Therefore, None is explicitly returned to distinguish it from a lack of results. --- bot/cogs/codeblock/cog.py | 3 +++ bot/cogs/codeblock/parsing.py | 12 ++++++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index e4b87938d..15dffce7a 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -106,6 +106,9 @@ class CodeBlockCog(Cog, name="Code Block"): return blocks = parsing.find_code_blocks(msg.content) + if blocks is None: + # None is returned when there's at least one valid block with a language. + return if not blocks: log.trace(f"No code blocks were found in message {msg.id}.") description = instructions.get_no_ticks_message(msg.content) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 7409653d7..055c21118 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -1,7 +1,7 @@ import ast import logging import re -from typing import NamedTuple, Sequence +from typing import NamedTuple, Optional, Sequence log = logging.getLogger(__name__) @@ -41,15 +41,15 @@ class CodeBlock(NamedTuple): tick: str -def find_code_blocks(message: str) -> Sequence[CodeBlock]: +def find_code_blocks(message: str) -> Optional[Sequence[CodeBlock]]: """ Find and return all Markdown code blocks in the `message`. Code blocks with 3 or less lines are excluded. If the `message` contains at least one code block with valid ticks and a specified language, - return an empty sequence. This is based on the assumption that if the user managed to get - one code block right, they already know how to fix the rest themselves. + return None. This is based on the assumption that if the user managed to get one code block + right, they already know how to fix the rest themselves. """ log.trace("Finding all code blocks in a message.") @@ -60,8 +60,8 @@ def find_code_blocks(message: str) -> Sequence[CodeBlock]: language = groups["lang"].strip() # Strip the newline cause it's included in the group. if groups["tick"] == BACKTICK and language: - log.trace("Message has a valid code block with a language; returning empty tuple.") - return () + log.trace("Message has a valid code block with a language; returning None.") + return None elif len(groups["code"].split("\n", 3)) > 3: code_block = CodeBlock(groups["code"], language, groups["tick"]) code_blocks.append(code_block) -- cgit v1.2.3 From 45a13341f0eba0b04d57a5e240748e4939ab97a3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 22:58:43 -0700 Subject: Code block: move instructions deletion to a separate function --- bot/cogs/codeblock/cog.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 15dffce7a..396353d40 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -59,6 +59,21 @@ class CodeBlockCog(Cog, name="Code Block"): or channel.id in self.channel_whitelist ) + async def remove_instructions(self, payload: RawMessageUpdateEvent) -> None: + """ + Remove the code block instructions message. + + `payload` is the data for the message edit event performed by a user which resulted in their + code blocks being corrected. + """ + log.trace("User's incorrect code block has been fixed. Removing instructions message.") + + channel = self.bot.get_channel(int(payload.data.get("channel_id"))) + bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) + + await bot_message.delete() + del self.codeblock_message_ids[payload.message_id] + async def send_guide_embed(self, message: discord.Message, description: str) -> None: """ Send an embed with `description` as a guide for an improperly formatted `message`. @@ -153,10 +168,4 @@ class CodeBlockCog(Cog, name="Code Block"): # If the message is fixed, delete the bot message and the entry from the id dictionary. if not code_blocks: - log.trace("User's incorrect code block has been fixed. Removing bot formatting message.") - - channel = self.bot.get_channel(int(payload.data.get("channel_id"))) - bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) - - await bot_message.delete() - del self.codeblock_message_ids[payload.message_id] + await self.remove_instructions(payload) -- cgit v1.2.3 From e03c194242b16d5f5ef9d937a13daef424800bec Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 23:12:20 -0700 Subject: Code block: move instructions retrieval to a separate function Not only is it cleaner and more testable, but it allows for other functions to also retrieve instructions. --- bot/cogs/codeblock/cog.py | 32 ++++++-------------------------- bot/cogs/codeblock/instructions.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 26 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 396353d40..23d5267a9 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -8,7 +8,8 @@ from discord.ext.commands import Bot, Cog from bot.cogs.token_remover import TokenRemover from bot.constants import Categories, Channels, DEBUG_MODE from bot.utils.messages import wait_for_deletion -from . import instructions, parsing +from . import parsing +from .instructions import get_instructions log = logging.getLogger(__name__) @@ -120,31 +121,10 @@ class CodeBlockCog(Cog, name="Code Block"): log.trace(f"Skipping code block detection of {msg.id}: #{msg.channel} is on cooldown.") return - blocks = parsing.find_code_blocks(msg.content) - if blocks is None: - # None is returned when there's at least one valid block with a language. - return - if not blocks: - log.trace(f"No code blocks were found in message {msg.id}.") - description = instructions.get_no_ticks_message(msg.content) - else: - log.trace("Searching results for a code block with invalid ticks.") - block = next((block for block in blocks if block.tick != parsing.BACKTICK), None) - - if block: - log.trace(f"A code block exists in {msg.id} but has invalid ticks.") - description = instructions.get_bad_ticks_message(block) - else: - log.trace(f"A code block exists in {msg.id} but is missing a language.") - block = blocks[0] - - # Check for a bad language first to avoid parsing content into an AST. - description = instructions.get_bad_lang_message(block.content) - if not description: - description = instructions.get_no_lang_message(block.content) - - if description: - await self.send_guide_embed(msg, description) + instructions = get_instructions(msg.content) + if instructions: + await self.send_guide_embed(msg, instructions) + if msg.channel.id not in self.channel_whitelist: log.trace(f"Adding #{msg.channel} to the channel cooldowns.") self.channel_cooldowns[msg.channel.id] = time.time() diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 28242ce75..d331dd2ee 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -133,3 +133,34 @@ def get_no_lang_message(content: str) -> Optional[str]: ) else: log.trace("Aborting missing language instructions: content is not Python code.") + + +def get_instructions(content: str) -> Optional[str]: + """Return code block formatting instructions for `content` or None if nothing's wrong.""" + log.trace("Getting formatting instructions.") + + blocks = parsing.find_code_blocks(content) + if blocks is None: + log.trace("At least one valid code block found; no instructions to return.") + return + + if not blocks: + log.trace(f"No code blocks were found in message.") + return get_no_ticks_message(content) + else: + log.trace("Searching results for a code block with invalid ticks.") + block = next((block for block in blocks if block.tick != parsing.BACKTICK), None) + + if block: + log.trace(f"A code block exists but has invalid ticks.") + return get_bad_ticks_message(block) + else: + log.trace(f"A code block exists but is missing a language.") + block = blocks[0] + + # Check for a bad language first to avoid parsing content into an AST. + description = get_bad_lang_message(block.content) + if not description: + description = get_no_lang_message(block.content) + + return description -- cgit v1.2.3 From ee8dae3ff890369ba7cd9badaa0e45ddcb926c8c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 23:29:13 -0700 Subject: Code block: move bot message retrieval to a separate function This bot message retrieval is the actual part of `remove_instructions` that will soon get re-used elsewhere. * Remove `remove_instructions` since it became a bit too simple given the separation of bot message retrieval. --- bot/cogs/codeblock/cog.py | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 23d5267a9..276bf8f9b 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -33,6 +33,13 @@ class CodeBlockCog(Cog, name="Code Block"): # Stores improperly formatted Python codeblock message ids and the corresponding bot message self.codeblock_message_ids = {} + async def get_sent_instructions(self, payload: RawMessageUpdateEvent) -> discord.Message: + """Return the bot's sent instructions message using the user message ID from a `payload`.""" + log.trace(f"Retrieving instructions message for ID {payload.message_id}") + + channel = self.bot.get_channel(int(payload.data.get("channel_id"))) + return await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) + @staticmethod def is_help_channel(channel: discord.TextChannel) -> bool: """Return True if `channel` is in one of the help categories.""" @@ -60,21 +67,6 @@ class CodeBlockCog(Cog, name="Code Block"): or channel.id in self.channel_whitelist ) - async def remove_instructions(self, payload: RawMessageUpdateEvent) -> None: - """ - Remove the code block instructions message. - - `payload` is the data for the message edit event performed by a user which resulted in their - code blocks being corrected. - """ - log.trace("User's incorrect code block has been fixed. Removing instructions message.") - - channel = self.bot.get_channel(int(payload.data.get("channel_id"))) - bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) - - await bot_message.delete() - del self.codeblock_message_ids[payload.message_id] - async def send_guide_embed(self, message: discord.Message, description: str) -> None: """ Send an embed with `description` as a guide for an improperly formatted `message`. @@ -148,4 +140,7 @@ class CodeBlockCog(Cog, name="Code Block"): # If the message is fixed, delete the bot message and the entry from the id dictionary. if not code_blocks: - await self.remove_instructions(payload) + log.trace("User's incorrect code block has been fixed. Removing instructions message.") + bot_message = await self.get_sent_instructions(payload) + await bot_message.delete() + del self.codeblock_message_ids[payload.message_id] -- cgit v1.2.3 From fd4bed07a08a5fdbd482345c99838131dba45e98 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 23:35:20 -0700 Subject: Code block: edit instructions if edited message is still invalid Editing instructions means the user will always see what is currently relevant to them. Sometimes an incorrect edit could result in a different problem that was not mentioned in the original instructions. This change also fixes detection of fixed messages by using the same detection logic as the original `on_message`. Previously, it considered an edited message without code blocks to be fixed. --- bot/cogs/codeblock/cog.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 276bf8f9b..5844f4d16 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -8,7 +8,6 @@ from discord.ext.commands import Bot, Cog from bot.cogs.token_remover import TokenRemover from bot.constants import Categories, Channels, DEBUG_MODE from bot.utils.messages import wait_for_deletion -from . import parsing from .instructions import get_instructions log = logging.getLogger(__name__) @@ -136,11 +135,14 @@ class CodeBlockCog(Cog, name="Code Block"): return # Parse the message to see if the code blocks have been fixed. - code_blocks = parsing.find_code_blocks(payload.data.get("content")) + content = payload.data.get("content") + instructions = get_instructions(content) + bot_message = await self.get_sent_instructions(payload) - # If the message is fixed, delete the bot message and the entry from the id dictionary. - if not code_blocks: + if not instructions: log.trace("User's incorrect code block has been fixed. Removing instructions message.") - bot_message = await self.get_sent_instructions(payload) await bot_message.delete() del self.codeblock_message_ids[payload.message_id] + else: + log.trace("Message edited but still has invalid code blocks; editing the instructions.") + await bot_message.edit(content=instructions) -- cgit v1.2.3 From b86d9a66519b2c8b8c50c255c8b23d924be35f5a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 11:09:44 -0700 Subject: Code block: clarify log messages in message edit event If statement was separated so there could be separate messages that are more specific. The message ID was also included to distinguish events. --- bot/cogs/codeblock/cog.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 5844f4d16..0f0a8cd51 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -123,15 +123,12 @@ class CodeBlockCog(Cog, name="Code Block"): @Cog.listener() async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: """Delete the instructions message if an edited message had its code blocks fixed.""" - if ( - # Checks to see if the message was called out by the bot - payload.message_id not in self.codeblock_message_ids - # Makes sure that there is content in the message - or payload.data.get("content") is None - # Makes sure there's a channel id in the message payload - or payload.data.get("channel_id") is None - ): - log.trace("Message edit does not qualify for code block detection.") + if payload.message_id not in self.codeblock_message_ids: + log.trace(f"Ignoring message edit {payload.message_id}: message isn't being tracked.") + return + + if payload.data.get("content") is None or payload.data.get("channel_id") is None: + log.trace(f"Ignoring message edit {payload.message_id}: missing content or channel ID.") return # Parse the message to see if the code blocks have been fixed. -- cgit v1.2.3 From 3728d8a1e8bbf9cfb0dce7a9a548c6527b554290 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 11:16:41 -0700 Subject: Code block: fix error retrieving a deleted instructions message --- bot/cogs/codeblock/cog.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 0f0a8cd51..f64ac8c45 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -1,5 +1,6 @@ import logging import time +from typing import Optional import discord from discord import Embed, Message, RawMessageUpdateEvent @@ -32,12 +33,21 @@ class CodeBlockCog(Cog, name="Code Block"): # Stores improperly formatted Python codeblock message ids and the corresponding bot message self.codeblock_message_ids = {} - async def get_sent_instructions(self, payload: RawMessageUpdateEvent) -> discord.Message: - """Return the bot's sent instructions message using the user message ID from a `payload`.""" - log.trace(f"Retrieving instructions message for ID {payload.message_id}") + async def get_sent_instructions(self, payload: RawMessageUpdateEvent) -> Optional[Message]: + """ + Return the bot's sent instructions message associated with a user's message `payload`. + Return None if the message cannot be found. In this case, it's likely the message was + deleted either manually via a reaction or automatically by a timer. + """ + log.trace(f"Retrieving instructions message for ID {payload.message_id}") channel = self.bot.get_channel(int(payload.data.get("channel_id"))) - return await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) + + try: + return await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) + except discord.NotFound: + log.debug("Could not find instructions message; it was probably deleted.") + return None @staticmethod def is_help_channel(channel: discord.TextChannel) -> bool: @@ -134,7 +144,10 @@ class CodeBlockCog(Cog, name="Code Block"): # Parse the message to see if the code blocks have been fixed. content = payload.data.get("content") instructions = get_instructions(content) + bot_message = await self.get_sent_instructions(payload) + if not bot_message: + return if not instructions: log.trace("User's incorrect code block has been fixed. Removing instructions message.") -- cgit v1.2.3 From 2694cbff786154fb8ba1211b0954f12312b71016 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 12:55:16 -0700 Subject: Code block: refactor `send_guide_embed` * Rename to `send_instructions` to be consistent with the use of "instructions" rather than "guide" elsewhere * Rename the `description` parameter to `instructions` --- bot/cogs/codeblock/cog.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index f64ac8c45..38daa7974 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -76,15 +76,15 @@ class CodeBlockCog(Cog, name="Code Block"): or channel.id in self.channel_whitelist ) - async def send_guide_embed(self, message: discord.Message, description: str) -> None: + async def send_instructions(self, message: discord.Message, instructions: str) -> None: """ - Send an embed with `description` as a guide for an improperly formatted `message`. + Send an embed with `instructions` on fixing an incorrect code block in a `message`. The embed will be deleted automatically after 5 minutes. """ log.trace("Sending an embed with code block formatting instructions.") - embed = Embed(description=description) + embed = Embed(description=instructions) bot_message = await message.channel.send(f"Hey {message.author.mention}!", embed=embed) self.codeblock_message_ids[message.id] = bot_message.id @@ -124,7 +124,7 @@ class CodeBlockCog(Cog, name="Code Block"): instructions = get_instructions(msg.content) if instructions: - await self.send_guide_embed(msg, instructions) + await self.send_instructions(msg, instructions) if msg.channel.id not in self.channel_whitelist: log.trace(f"Adding #{msg.channel} to the channel cooldowns.") -- cgit v1.2.3 From 7468aff92bc6cd658b334d89e7049c98b8ae0439 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 16:26:17 -0700 Subject: Code block: rename some things to be "private" --- bot/cogs/codeblock/instructions.py | 44 +++++++++++++++++++------------------- bot/cogs/codeblock/parsing.py | 8 +++---- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index d331dd2ee..abdf092fe 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -5,16 +5,16 @@ from . import parsing log = logging.getLogger(__name__) -PY_LANG_CODES = ("python", "py") # Order is important; "py" is second cause it's a subset. -EXAMPLE_PY = "{lang}\nprint('Hello, world!')" # Make sure to escape any Markdown symbols here. -EXAMPLE_CODE_BLOCKS = ( +_PY_LANG_CODES = ("python", "py") # Order is important; "py" is second cause it's a subset. +_EXAMPLE_PY = "{lang}\nprint('Hello, world!')" # Make sure to escape any Markdown symbols here. +_EXAMPLE_CODE_BLOCKS = ( "\\`\\`\\`{content}\n\\`\\`\\`\n\n" "**This will result in the following:**\n" "```{content}```" ) -def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: +def _get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: """Return instructions on using the correct ticks for `code_block`.""" log.trace("Creating instructions for incorrect code block ticks.") valid_ticks = f"\\{parsing.BACKTICK}" * 3 @@ -27,9 +27,9 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: ) log.trace("Check if the bad ticks code block also has issues with the language specifier.") - addition_msg = get_bad_lang_message(code_block.content) + addition_msg = _get_bad_lang_message(code_block.content) if not addition_msg: - addition_msg = get_no_lang_message(code_block.content) + addition_msg = _get_no_lang_message(code_block.content) # Combine the back ticks message with the language specifier message. The latter will # already have an example code block. @@ -45,9 +45,9 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: log.trace("No issues with the language specifier found.") # Determine the example code to put in the code block based on the language specifier. - if code_block.language.lower() in PY_LANG_CODES: + if code_block.language.lower() in _PY_LANG_CODES: log.trace(f"Code block has a Python language specifier `{code_block.language}`.") - content = EXAMPLE_PY.format(lang=code_block.language) + content = _EXAMPLE_PY.format(lang=code_block.language) elif code_block.language: log.trace(f"Code block has a foreign language specifier `{code_block.language}`.") # It's not feasible to determine what would be a valid example for other languages. @@ -56,18 +56,18 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: log.trace("Code block has no language specifier (and the code isn't valid Python).") content = "Hello, world!" - example_blocks = EXAMPLE_CODE_BLOCKS.format(content=content) + example_blocks = _EXAMPLE_CODE_BLOCKS.format(content=content) instructions += f"\n\n**Here is an example of how it should look:**\n{example_blocks}" return instructions -def get_no_ticks_message(content: str) -> Optional[str]: +def _get_no_ticks_message(content: str) -> Optional[str]: """If `content` is Python/REPL code, return instructions on using code blocks.""" log.trace("Creating instructions for a missing code block.") if parsing.is_repl_code(content) or parsing.is_python_code(content): - example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY.format(lang="python")) + example_blocks = _EXAMPLE_CODE_BLOCKS.format(content=_EXAMPLE_PY.format(lang="python")) return ( "It looks like you're trying to paste code into this channel.\n\n" "Discord has support for Markdown, which allows you to post code with full " @@ -79,7 +79,7 @@ def get_no_ticks_message(content: str) -> Optional[str]: log.trace("Aborting missing code block instructions: content is not Python code.") -def get_bad_lang_message(content: str) -> Optional[str]: +def _get_bad_lang_message(content: str) -> Optional[str]: """ Return instructions on fixing the Python language specifier for a code block. @@ -88,10 +88,10 @@ def get_bad_lang_message(content: str) -> Optional[str]: log.trace("Creating instructions for a poorly specified language.") stripped = content.lstrip().lower() - lang = next((lang for lang in PY_LANG_CODES if stripped.startswith(lang)), None) + lang = next((lang for lang in _PY_LANG_CODES if stripped.startswith(lang)), None) if lang: - # Note that get_bad_ticks_message expects the first line to have an extra newline. + # Note that _get_bad_ticks_message expects the first line to have an extra newline. lines = ["It looks like you incorrectly specified a language for your code block.\n"] if content.startswith(" "): @@ -105,7 +105,7 @@ def get_bad_lang_message(content: str) -> Optional[str]: f"There must not be any spaces after `{lang}`." ) - example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY.format(lang=lang)) + example_blocks = _EXAMPLE_CODE_BLOCKS.format(content=_EXAMPLE_PY.format(lang=lang)) lines.append(f"\n**Here is an example of how it should look:**\n{example_blocks}") return "\n".join(lines) @@ -113,7 +113,7 @@ def get_bad_lang_message(content: str) -> Optional[str]: log.trace("Aborting bad language instructions: language specified isn't Python.") -def get_no_lang_message(content: str) -> Optional[str]: +def _get_no_lang_message(content: str) -> Optional[str]: """ Return instructions on specifying a language for a code block. @@ -122,9 +122,9 @@ def get_no_lang_message(content: str) -> Optional[str]: log.trace("Creating instructions for a missing language.") if parsing.is_repl_code(content) or parsing.is_python_code(content): - example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY.format(lang="python")) + example_blocks = _EXAMPLE_CODE_BLOCKS.format(content=_EXAMPLE_PY.format(lang="python")) - # Note that get_bad_ticks_message expects the first line to have an extra newline. + # Note that _get_bad_ticks_message expects the first line to have an extra newline. return ( "It looks like you pasted Python code without syntax highlighting.\n\n" "Please use syntax highlighting to improve the legibility of your code and make " @@ -146,21 +146,21 @@ def get_instructions(content: str) -> Optional[str]: if not blocks: log.trace(f"No code blocks were found in message.") - return get_no_ticks_message(content) + return _get_no_ticks_message(content) else: log.trace("Searching results for a code block with invalid ticks.") block = next((block for block in blocks if block.tick != parsing.BACKTICK), None) if block: log.trace(f"A code block exists but has invalid ticks.") - return get_bad_ticks_message(block) + return _get_bad_ticks_message(block) else: log.trace(f"A code block exists but is missing a language.") block = blocks[0] # Check for a bad language first to avoid parsing content into an AST. - description = get_bad_lang_message(block.content) + description = _get_bad_lang_message(block.content) if not description: - description = get_no_lang_message(block.content) + description = _get_no_lang_message(block.content) return description diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 055c21118..a49ecc8f7 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -6,7 +6,7 @@ from typing import NamedTuple, Optional, Sequence log = logging.getLogger(__name__) BACKTICK = "`" -TICKS = { +_TICKS = { BACKTICK, "'", '"', @@ -19,10 +19,10 @@ TICKS = { "\u2033", # DOUBLE PRIME "\u3003", # VERTICAL KANA REPEAT MARK UPPER HALF } -RE_CODE_BLOCK = re.compile( +_RE_CODE_BLOCK = re.compile( fr""" (?P - (?P[{''.join(TICKS)}]) # Put all ticks into a character class within a group. + (?P[{''.join(_TICKS)}]) # Put all ticks into a character class within a group. \2{{2}} # Match previous group 2 more times to ensure the same char. ) (?P[^\W_]+\n)? # Optionally match a language specifier followed by a newline. @@ -54,7 +54,7 @@ def find_code_blocks(message: str) -> Optional[Sequence[CodeBlock]]: log.trace("Finding all code blocks in a message.") code_blocks = [] - for match in RE_CODE_BLOCK.finditer(message): + for match in _RE_CODE_BLOCK.finditer(message): # Used to ensure non-matched groups have an empty string as the default value. groups = match.groupdict("") language = groups["lang"].strip() # Strip the newline cause it's included in the group. -- cgit v1.2.3 From c98666d42e325cc8de11d6a271015b2a546a65b1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 17:22:23 -0700 Subject: Code block: create a function to format the example code blocks First, this reduces code redundancy. Furthermore, it moves the relatively big block of code for checking the language away from `_get_bad_ticks_message` and into its own, smaller unit. --- bot/cogs/codeblock/instructions.py | 40 ++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index abdf092fe..bba84c66a 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -14,6 +14,25 @@ _EXAMPLE_CODE_BLOCKS = ( ) +def _get_example(language: str) -> str: + """Return an example of a correct code block using `language` for syntax highlighting.""" + language_lower = language.lower() # It's only valid if it's all lowercase. + + # Determine the example code to put in the code block based on the language specifier. + if language_lower in _PY_LANG_CODES: + log.trace(f"Code block has a Python language specifier `{language}`.") + content = _EXAMPLE_PY.format(lang=language_lower) + elif language_lower: + log.trace(f"Code block has a foreign language specifier `{language}`.") + # It's not feasible to determine what would be a valid example for other languages. + content = f"{language_lower}\n..." + else: + log.trace("Code block has no language specifier.") + content = "Hello, world!" + + return _EXAMPLE_CODE_BLOCKS.format(content=content) + + def _get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: """Return instructions on using the correct ticks for `code_block`.""" log.trace("Creating instructions for incorrect code block ticks.") @@ -43,20 +62,7 @@ def _get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: instructions += "\n\nFurthermore, " + addition_msg[0].lower() + addition_msg[1:] else: log.trace("No issues with the language specifier found.") - - # Determine the example code to put in the code block based on the language specifier. - if code_block.language.lower() in _PY_LANG_CODES: - log.trace(f"Code block has a Python language specifier `{code_block.language}`.") - content = _EXAMPLE_PY.format(lang=code_block.language) - elif code_block.language: - log.trace(f"Code block has a foreign language specifier `{code_block.language}`.") - # It's not feasible to determine what would be a valid example for other languages. - content = f"{code_block.language}\n..." - else: - log.trace("Code block has no language specifier (and the code isn't valid Python).") - content = "Hello, world!" - - example_blocks = _EXAMPLE_CODE_BLOCKS.format(content=content) + example_blocks = _get_example(code_block.language) instructions += f"\n\n**Here is an example of how it should look:**\n{example_blocks}" return instructions @@ -67,7 +73,7 @@ def _get_no_ticks_message(content: str) -> Optional[str]: log.trace("Creating instructions for a missing code block.") if parsing.is_repl_code(content) or parsing.is_python_code(content): - example_blocks = _EXAMPLE_CODE_BLOCKS.format(content=_EXAMPLE_PY.format(lang="python")) + example_blocks = _get_example("python") return ( "It looks like you're trying to paste code into this channel.\n\n" "Discord has support for Markdown, which allows you to post code with full " @@ -105,7 +111,7 @@ def _get_bad_lang_message(content: str) -> Optional[str]: f"There must not be any spaces after `{lang}`." ) - example_blocks = _EXAMPLE_CODE_BLOCKS.format(content=_EXAMPLE_PY.format(lang=lang)) + example_blocks = _get_example(lang) lines.append(f"\n**Here is an example of how it should look:**\n{example_blocks}") return "\n".join(lines) @@ -122,7 +128,7 @@ def _get_no_lang_message(content: str) -> Optional[str]: log.trace("Creating instructions for a missing language.") if parsing.is_repl_code(content) or parsing.is_python_code(content): - example_blocks = _EXAMPLE_CODE_BLOCKS.format(content=_EXAMPLE_PY.format(lang="python")) + example_blocks = _get_example("python") # Note that _get_bad_ticks_message expects the first line to have an extra newline. return ( -- cgit v1.2.3 From 2bfac307c4b06682db93e2a75108012a586d1c7d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 18:33:34 -0700 Subject: Code block: use regex to parse incorrect languages Regex is simpler and more versatile in this case. The functions in the `instructions` module should be more focused on formatting than parsing, so the parsing was moved to the `parsing` module. * Move _PY_LANG_CODES to the `parsing` module * Create a separate function in the `parsing` module to parse bad languages --- bot/cogs/codeblock/instructions.py | 30 +++++++++++++---------------- bot/cogs/codeblock/parsing.py | 39 +++++++++++++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 18 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index bba84c66a..c1a6645b3 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -5,7 +5,6 @@ from . import parsing log = logging.getLogger(__name__) -_PY_LANG_CODES = ("python", "py") # Order is important; "py" is second cause it's a subset. _EXAMPLE_PY = "{lang}\nprint('Hello, world!')" # Make sure to escape any Markdown symbols here. _EXAMPLE_CODE_BLOCKS = ( "\\`\\`\\`{content}\n\\`\\`\\`\n\n" @@ -16,16 +15,14 @@ _EXAMPLE_CODE_BLOCKS = ( def _get_example(language: str) -> str: """Return an example of a correct code block using `language` for syntax highlighting.""" - language_lower = language.lower() # It's only valid if it's all lowercase. - # Determine the example code to put in the code block based on the language specifier. - if language_lower in _PY_LANG_CODES: + if language.lower() in parsing.PY_LANG_CODES: log.trace(f"Code block has a Python language specifier `{language}`.") - content = _EXAMPLE_PY.format(lang=language_lower) - elif language_lower: + content = _EXAMPLE_PY.format(lang=language) + elif language: log.trace(f"Code block has a foreign language specifier `{language}`.") # It's not feasible to determine what would be a valid example for other languages. - content = f"{language_lower}\n..." + content = f"{language}\n..." else: log.trace("Code block has no language specifier.") content = "Hello, world!" @@ -92,26 +89,25 @@ def _get_bad_lang_message(content: str) -> Optional[str]: If `content` doesn't start with "python" or "py" as the language specifier, return None. """ log.trace("Creating instructions for a poorly specified language.") + info = parsing.parse_bad_language(content) - stripped = content.lstrip().lower() - lang = next((lang for lang in _PY_LANG_CODES if stripped.startswith(lang)), None) - - if lang: + if info: # Note that _get_bad_ticks_message expects the first line to have an extra newline. lines = ["It looks like you incorrectly specified a language for your code block.\n"] + language = info.language - if content.startswith(" "): + if info.leading_spaces: log.trace("Language specifier was preceded by a space.") - lines.append(f"Make sure there are no spaces between the back ticks and `{lang}`.") + lines.append(f"Make sure there are no spaces between the back ticks and `{language}`.") - if stripped[len(lang)] != "\n": + if not info.terminal_newline: log.trace("Language specifier was not followed by a newline.") lines.append( - f"Make sure you put your code on a new line following `{lang}`. " - f"There must not be any spaces after `{lang}`." + f"Make sure you put your code on a new line following `{language}`. " + f"There must not be any spaces after `{language}`." ) - example_blocks = _get_example(lang) + example_blocks = _get_example(language) lines.append(f"\n**Here is an example of how it should look:**\n{example_blocks}") return "\n".join(lines) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index a49ecc8f7..6fa6811cc 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -22,7 +22,7 @@ _TICKS = { _RE_CODE_BLOCK = re.compile( fr""" (?P - (?P[{''.join(_TICKS)}]) # Put all ticks into a character class within a group. + (?P[{''.join(_TICKS)}]) # Put all ticks into a character class within a group. \2{{2}} # Match previous group 2 more times to ensure the same char. ) (?P[^\W_]+\n)? # Optionally match a language specifier followed by a newline. @@ -32,6 +32,16 @@ _RE_CODE_BLOCK = re.compile( re.DOTALL | re.VERBOSE ) +PY_LANG_CODES = ("python", "py") # Order is important; "py" is second cause it's a subset. +_RE_LANGUAGE = re.compile( + fr""" + ^(?P\s+)? # Optionally match leading spaces from the beginning. + (?P{'|'.join(PY_LANG_CODES)}) # Match a Python language. + (?P\n)? # Optionally match a newline following the language. + """, + re.IGNORECASE | re.VERBOSE +) + class CodeBlock(NamedTuple): """Represents a Markdown code block.""" @@ -41,6 +51,14 @@ class CodeBlock(NamedTuple): tick: str +class BadLanguage(NamedTuple): + """Parsed information about a poorly formatted language specifier.""" + + language: str + leading_spaces: bool + terminal_newline: bool + + def find_code_blocks(message: str) -> Optional[Sequence[CodeBlock]]: """ Find and return all Markdown code blocks in the `message`. @@ -108,3 +126,22 @@ def is_repl_code(content: str, threshold: int = 3) -> bool: log.trace("Content is not Python REPL code.") return False + + +def parse_bad_language(content: str) -> Optional[BadLanguage]: + """ + Return information about a poorly formatted Python language in code block `content`. + + If the language is not Python, return None. + """ + log.trace("Parsing bad language.") + + match = _RE_LANGUAGE.match(content) + if not match: + return None + + return BadLanguage( + language=match["lang"], + leading_spaces=match["spaces"] is not None, + terminal_newline=match["newline"] is not None, + ) -- cgit v1.2.3 From ae0f29ee8680c75d59eefa2f1563f6c906539aa9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 19:18:47 -0700 Subject: Code block: add function to create the instructions embed While it may be simple now, if the embed needs to changed later, it won't need to be done in multiple places since everything can rely on this function to create the embed. --- bot/cogs/codeblock/cog.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 38daa7974..ca787b181 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -3,7 +3,7 @@ import time from typing import Optional import discord -from discord import Embed, Message, RawMessageUpdateEvent +from discord import Message, RawMessageUpdateEvent from discord.ext.commands import Bot, Cog from bot.cogs.token_remover import TokenRemover @@ -33,6 +33,11 @@ class CodeBlockCog(Cog, name="Code Block"): # Stores improperly formatted Python codeblock message ids and the corresponding bot message self.codeblock_message_ids = {} + @staticmethod + def create_embed(instructions: str) -> discord.Embed: + """Return an embed which displays code block formatting `instructions`.""" + return discord.Embed(description=instructions) + async def get_sent_instructions(self, payload: RawMessageUpdateEvent) -> Optional[Message]: """ Return the bot's sent instructions message associated with a user's message `payload`. @@ -84,7 +89,7 @@ class CodeBlockCog(Cog, name="Code Block"): """ log.trace("Sending an embed with code block formatting instructions.") - embed = Embed(description=instructions) + embed = self.create_embed(instructions) bot_message = await message.channel.send(f"Hey {message.author.mention}!", embed=embed) self.codeblock_message_ids[message.id] = bot_message.id -- cgit v1.2.3 From cad6957b233ed905ed76d066517866255c8ae7a4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 19:19:46 -0700 Subject: Code block: fix message content being edited instead of the embed --- bot/cogs/codeblock/cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index ca787b181..80d5adff3 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -160,4 +160,4 @@ class CodeBlockCog(Cog, name="Code Block"): del self.codeblock_message_ids[payload.message_id] else: log.trace("Message edited but still has invalid code blocks; editing the instructions.") - await bot_message.edit(content=instructions) + await bot_message.edit(embed=self.create_embed(instructions)) -- cgit v1.2.3 From 4b1a1cdd91023baa0da9959e1cc8b811c0aa9795 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 19:34:20 -0700 Subject: Code block: join bad language instructions by spaces It was a mistake to join them by newlines in the first place. It looks and reads better as a paragraph. * Remove extra space after bad ticks instructions --- bot/cogs/codeblock/instructions.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index c1a6645b3..3cc955a1a 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -33,13 +33,12 @@ def _get_example(language: str) -> str: def _get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: """Return instructions on using the correct ticks for `code_block`.""" log.trace("Creating instructions for incorrect code block ticks.") - valid_ticks = f"\\{parsing.BACKTICK}" * 3 - # The space at the end is important here because something may be appended! + valid_ticks = f"\\{parsing.BACKTICK}" * 3 instructions = ( "It looks like you are trying to paste code into this channel.\n\n" "You seem to be using the wrong symbols to indicate where the code block should start. " - f"The correct symbols would be {valid_ticks}, not `{code_block.tick * 3}`. " + f"The correct symbols would be {valid_ticks}, not `{code_block.tick * 3}`." ) log.trace("Check if the bad ticks code block also has issues with the language specifier.") @@ -52,7 +51,7 @@ def _get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: if addition_msg: log.trace("Language specifier issue found; appending additional instructions.") - # The first line has a double line break which is not desirable when appending the msg. + # The first line has double newlines which are not desirable when appending the msg. addition_msg = addition_msg.replace("\n\n", " ", 1) # Make the first character of the addition lower case. @@ -92,8 +91,7 @@ def _get_bad_lang_message(content: str) -> Optional[str]: info = parsing.parse_bad_language(content) if info: - # Note that _get_bad_ticks_message expects the first line to have an extra newline. - lines = ["It looks like you incorrectly specified a language for your code block.\n"] + lines = [] language = info.language if info.leading_spaces: @@ -107,10 +105,14 @@ def _get_bad_lang_message(content: str) -> Optional[str]: f"There must not be any spaces after `{language}`." ) + lines = " ".join(lines) example_blocks = _get_example(language) - lines.append(f"\n**Here is an example of how it should look:**\n{example_blocks}") - return "\n".join(lines) + # Note that _get_bad_ticks_message expects the first line to have two newlines. + return ( + f"It looks like you incorrectly specified a language for your code block.\n\n{lines}" + f"\n\n**Here is an example of how it should look:**\n{example_blocks}" + ) else: log.trace("Aborting bad language instructions: language specified isn't Python.") @@ -126,7 +128,7 @@ def _get_no_lang_message(content: str) -> Optional[str]: if parsing.is_repl_code(content) or parsing.is_python_code(content): example_blocks = _get_example("python") - # Note that _get_bad_ticks_message expects the first line to have an extra newline. + # Note that _get_bad_ticks_message expects the first line to have two newlines. return ( "It looks like you pasted Python code without syntax highlighting.\n\n" "Please use syntax highlighting to improve the legibility of your code and make " -- cgit v1.2.3 From b160119bbdcde230da44279ce3698fb800f5743e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 20:37:37 -0700 Subject: Code block: don't return bad language instructions if nothing's wrong --- bot/cogs/codeblock/instructions.py | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 3cc955a1a..0c97d2ad4 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -85,26 +85,31 @@ def _get_bad_lang_message(content: str) -> Optional[str]: """ Return instructions on fixing the Python language specifier for a code block. - If `content` doesn't start with "python" or "py" as the language specifier, return None. + If `code_block` does not have a Python language specifier, return None. + If there's nothing wrong with the language specifier, return None. """ log.trace("Creating instructions for a poorly specified language.") + info = parsing.parse_bad_language(content) + if not info: + log.trace("Aborting bad language instructions: language specified isn't Python.") + return - if info: - lines = [] - language = info.language + lines = [] + language = info.language - if info.leading_spaces: - log.trace("Language specifier was preceded by a space.") - lines.append(f"Make sure there are no spaces between the back ticks and `{language}`.") + if info.leading_spaces: + log.trace("Language specifier was preceded by a space.") + lines.append(f"Make sure there are no spaces between the back ticks and `{language}`.") - if not info.terminal_newline: - log.trace("Language specifier was not followed by a newline.") - lines.append( - f"Make sure you put your code on a new line following `{language}`. " - f"There must not be any spaces after `{language}`." - ) + if not info.terminal_newline: + log.trace("Language specifier was not followed by a newline.") + lines.append( + f"Make sure you put your code on a new line following `{language}`. " + f"There must not be any spaces after `{language}`." + ) + if lines: lines = " ".join(lines) example_blocks = _get_example(language) @@ -114,7 +119,7 @@ def _get_bad_lang_message(content: str) -> Optional[str]: f"\n\n**Here is an example of how it should look:**\n{example_blocks}" ) else: - log.trace("Aborting bad language instructions: language specified isn't Python.") + log.trace("Nothing wrong with the language specifier; no instructions to return.") def _get_no_lang_message(content: str) -> Optional[str]: -- cgit v1.2.3 From 7b2fff794907fed5e000998e876b7326fb938ca8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 20:46:09 -0700 Subject: Code block: fix wrong message shown for bad ticks with a valid language When the code block had invalid ticks, instructions for syntax highlighting were being shown despite the code block having a valid language. --- bot/cogs/codeblock/instructions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 0c97d2ad4..880572d58 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -43,7 +43,7 @@ def _get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: log.trace("Check if the bad ticks code block also has issues with the language specifier.") addition_msg = _get_bad_lang_message(code_block.content) - if not addition_msg: + if not addition_msg and not code_block.language: addition_msg = _get_no_lang_message(code_block.content) # Combine the back ticks message with the language specifier message. The latter will -- cgit v1.2.3 From 8fcbad9d2ee11916e398ae9f63826a90cdc45608 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 21:27:45 -0700 Subject: Code block: document the cog * Add docstrings for modules * Rephrase some docstrings and comments * Fix the grammar of some comments --- bot/cogs/codeblock/cog.py | 43 ++++++++++++++++++++++++++++++++------ bot/cogs/codeblock/instructions.py | 2 ++ bot/cogs/codeblock/parsing.py | 4 +++- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 80d5adff3..c1b2b1c68 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -15,22 +15,53 @@ log = logging.getLogger(__name__) class CodeBlockCog(Cog, name="Code Block"): - """Detect improperly formatted code blocks and suggest proper formatting.""" + """ + Detect improperly formatted Markdown code blocks and suggest proper formatting. + + There are four basic ways in which a code block is considered improperly formatted: + + 1. The code is not within a code block at all + * Ignored if the code is not valid Python or Python REPL code + 2. Incorrect characters are used for backticks + 3. A language for syntax highlighting is not specified + * Ignored if the code is not valid Python or Python REPL code + 4. A syntax highlighting language is incorrectly specified + * Ignored if the language specified doesn't look like it was meant for Python + * This can go wrong in two ways: + 1. Spaces before the language + 2. No newline immediately following the language + + Messages with 3 or fewer lines overall are ignored. Each code block is subject to this threshold + as well i.e. the text between the ticks must be greater than 3 lines. Detecting multiple code + blocks is supported. However, if at least one code block is correct, then instructions will not + be sent even if others are incorrect. When multiple incorrect code blocks are found, only the + first one is used as the basis for the instructions sent. + + When an issue is detected, an embed is sent containing specific instructions on fixing what + is wrong. If the user edits their message to fix the code block, the instructions will be + removed. If they fail to fix the code block with an edit, the instructions will be updated to + show what is still incorrect after the user's edit. The embed can be manually deleted with a + reaction. Otherwise, it will automatically be removed after 5 minutes. + + The cog only detects messages in whitelisted channels. Channels may also have a 300-second + cooldown on the instructions being sent. See `__init__` for which channels are whitelisted or + have cooldowns enabled. Note that all help channels are also whitelisted with cooldowns enabled. + """ def __init__(self, bot: Bot): self.bot = bot - # Stores allowed channels plus epoch time since last call. + # Stores allowed channels plus epoch times since the last instructional messages sent. self.channel_cooldowns = { Channels.python_discussion: 0, } - # These channels will also work, but will not be subject to cooldown + # These channels will also work, but will not be subject to a cooldown. self.channel_whitelist = ( Channels.bot_commands, ) - # Stores improperly formatted Python codeblock message ids and the corresponding bot message + # Maps users' messages to the messages the bot sent with instructions. self.codeblock_message_ids = {} @staticmethod @@ -73,7 +104,7 @@ class CodeBlockCog(Cog, name="Code Block"): return (time.time() - self.channel_cooldowns.get(channel.id, 0)) < 300 def is_valid_channel(self, channel: discord.TextChannel) -> bool: - """Return True if `channel` is a help channel, may be on cooldown, or is whitelisted.""" + """Return True if `channel` is a help channel, may be on a cooldown, or is whitelisted.""" log.trace(f"Checking if #{channel} qualifies for code block detection.") return ( self.is_help_channel(channel) @@ -137,7 +168,7 @@ class CodeBlockCog(Cog, name="Code Block"): @Cog.listener() async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: - """Delete the instructions message if an edited message had its code blocks fixed.""" + """Delete the instructional message if an edited message had its code blocks fixed.""" if payload.message_id not in self.codeblock_message_ids: log.trace(f"Ignoring message edit {payload.message_id}: message isn't being tracked.") return diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 880572d58..80f82ef34 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -1,3 +1,5 @@ +"""This module generates and formats instructional messages about fixing Markdown code blocks.""" + import logging from typing import Optional diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 6fa6811cc..1bdb3b492 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -1,3 +1,5 @@ +"""This module provides functions for parsing Markdown code blocks.""" + import ast import logging import re @@ -63,7 +65,7 @@ def find_code_blocks(message: str) -> Optional[Sequence[CodeBlock]]: """ Find and return all Markdown code blocks in the `message`. - Code blocks with 3 or less lines are excluded. + Code blocks with 3 or fewer lines are excluded. If the `message` contains at least one code block with valid ticks and a specified language, return None. This is based on the assumption that if the user managed to get one code block -- cgit v1.2.3 From 211aad8fc14ec81cb6e04cfaf70f6e50221bbc57 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 21:46:39 -0700 Subject: Move some functions into a new channel utility module * Change `is_help_channel` to`internally use `is_in_category` --- bot/cogs/codeblock/cog.py | 14 +++----------- bot/cogs/help_channels.py | 43 +++++++++++++++++-------------------------- bot/utils/channel.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 37 deletions(-) create mode 100644 bot/utils/channel.py diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index c1b2b1c68..3c119814f 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -7,7 +7,8 @@ from discord import Message, RawMessageUpdateEvent from discord.ext.commands import Bot, Cog from bot.cogs.token_remover import TokenRemover -from bot.constants import Categories, Channels, DEBUG_MODE +from bot.constants import Channels, DEBUG_MODE +from bot.utils.channel import is_help_channel from bot.utils.messages import wait_for_deletion from .instructions import get_instructions @@ -85,15 +86,6 @@ class CodeBlockCog(Cog, name="Code Block"): log.debug("Could not find instructions message; it was probably deleted.") return None - @staticmethod - def is_help_channel(channel: discord.TextChannel) -> bool: - """Return True if `channel` is in one of the help categories.""" - log.trace(f"Checking if #{channel} is a help channel.") - return ( - getattr(channel, "category", None) - and channel.category.id in (Categories.help_available, Categories.help_in_use) - ) - def is_on_cooldown(self, channel: discord.TextChannel) -> bool: """ Return True if an embed was sent for `channel` in the last 300 seconds. @@ -107,7 +99,7 @@ class CodeBlockCog(Cog, name="Code Block"): """Return True if `channel` is a help channel, may be on a cooldown, or is whitelisted.""" log.trace(f"Checking if #{channel} qualifies for code block detection.") return ( - self.is_help_channel(channel) + is_help_channel(channel) or channel.id in self.channel_cooldowns or channel.id in self.channel_whitelist ) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 6ff285c37..513ce31d0 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -15,6 +15,7 @@ from discord.ext import commands from bot import constants from bot.bot import Bot +from bot.utils import channel as channel_utils from bot.utils.checks import with_role_check from bot.utils.scheduling import Scheduler @@ -370,11 +371,18 @@ class HelpChannels(Scheduler, commands.Cog): log.trace("Getting the CategoryChannel objects for the help categories.") try: - self.available_category = await self.try_get_channel( - constants.Categories.help_available + self.available_category = await channel_utils.try_get_channel( + constants.Categories.help_available, + self.bot + ) + self.in_use_category = await channel_utils.try_get_channel( + constants.Categories.help_in_use, + self.bot + ) + self.dormant_category = await channel_utils.try_get_channel( + constants.Categories.help_dormant, + self.bot ) - self.in_use_category = await self.try_get_channel(constants.Categories.help_in_use) - self.dormant_category = await self.try_get_channel(constants.Categories.help_dormant) except discord.HTTPException: log.exception("Failed to get a category; cog will be removed") self.bot.remove_cog(self.qualified_name) @@ -431,12 +439,6 @@ class HelpChannels(Scheduler, commands.Cog): embed = message.embeds[0] return message.author == self.bot.user and embed.description.strip() == description.strip() - @staticmethod - def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: - """Return True if `channel` is within a category with `category_id`.""" - actual_category = getattr(channel, "category", None) - return actual_category is not None and actual_category.id == category_id - async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None: """ Make the `channel` dormant if idle or schedule the move if still active. @@ -488,7 +490,7 @@ class HelpChannels(Scheduler, commands.Cog): options should be avoided, as it may interfere with the category move we perform. """ # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had. - category = await self.try_get_channel(category_id) + category = await channel_utils.try_get_channel(category_id, self.bot) payload = [{"id": c.id, "position": c.position} for c in category.channels] @@ -634,7 +636,7 @@ class HelpChannels(Scheduler, commands.Cog): channel = message.channel # Confirm the channel is an in use help channel - if self.is_in_category(channel, constants.Categories.help_in_use): + if channel_utils.is_in_category(channel, constants.Categories.help_in_use): log.trace(f"Checking if #{channel} ({channel.id}) has been answered.") # Check if there is an entry in unanswered (does not persist across restarts) @@ -659,7 +661,8 @@ class HelpChannels(Scheduler, commands.Cog): await self.check_for_answer(message) - if not self.is_in_category(channel, constants.Categories.help_available) or self.is_excluded_channel(channel): + is_available = channel_utils.is_in_category(channel, constants.Categories.help_available) + if not is_available or self.is_excluded_channel(channel): return # Ignore messages outside the Available category or in excluded channels. log.trace("Waiting for the cog to be ready before processing messages.") @@ -669,7 +672,7 @@ class HelpChannels(Scheduler, commands.Cog): async with self.on_message_lock: log.trace(f"on_message lock acquired for {message.id}.") - if not self.is_in_category(channel, constants.Categories.help_available): + if not channel_utils.is_in_category(channel, constants.Categories.help_available): log.debug( f"Message {message.id} will not make #{channel} ({channel.id}) in-use " f"because another message in the channel already triggered that." @@ -802,18 +805,6 @@ class HelpChannels(Scheduler, commands.Cog): log.trace(f"Dormant message not found in {channel_info}; sending a new message.") await channel.send(embed=embed) - async def try_get_channel(self, channel_id: int) -> discord.abc.GuildChannel: - """Attempt to get or fetch a channel and return it.""" - log.trace(f"Getting the channel {channel_id}.") - - channel = self.bot.get_channel(channel_id) - if not channel: - log.debug(f"Channel {channel_id} is not in cache; fetching from API.") - channel = await self.bot.fetch_channel(channel_id) - - log.trace(f"Channel #{channel} ({channel_id}) retrieved.") - return channel - async def wait_for_dormant_channel(self) -> discord.TextChannel: """Wait for a dormant channel to become available in the queue and return it.""" log.trace("Waiting for a dormant channel.") diff --git a/bot/utils/channel.py b/bot/utils/channel.py new file mode 100644 index 000000000..47f70ce31 --- /dev/null +++ b/bot/utils/channel.py @@ -0,0 +1,34 @@ +import logging + +import discord + +from bot.constants import Categories + +log = logging.getLogger(__name__) + + +def is_help_channel(channel: discord.TextChannel) -> bool: + """Return True if `channel` is in one of the help categories (excluding dormant).""" + log.trace(f"Checking if #{channel} is a help channel.") + categories = (Categories.help_available, Categories.help_in_use) + + return any(is_in_category(channel, category) for category in categories) + + +def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: + """Return True if `channel` is within a category with `category_id`.""" + actual_category = getattr(channel, "category", None) + return actual_category is not None and actual_category.id == category_id + + +async def try_get_channel(channel_id: int, client: discord.Client) -> discord.abc.GuildChannel: + """Attempt to get or fetch a channel and return it.""" + log.trace(f"Getting the channel {channel_id}.") + + channel = client.get_channel(channel_id) + if not channel: + log.debug(f"Channel {channel_id} is not in cache; fetching from API.") + channel = await client.fetch_channel(channel_id) + + log.trace(f"Channel #{channel} ({channel_id}) retrieved.") + return channel -- cgit v1.2.3 From 4cd82783b4aec4e76ecbf1abf6549da68379dc66 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 22:19:12 -0700 Subject: Code block: fix missing newline before generic example --- bot/cogs/codeblock/instructions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 80f82ef34..5c573c2ff 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -27,7 +27,7 @@ def _get_example(language: str) -> str: content = f"{language}\n..." else: log.trace("Code block has no language specifier.") - content = "Hello, world!" + content = "\nHello, world!" return _EXAMPLE_CODE_BLOCKS.format(content=content) -- cgit v1.2.3 From a219c946a92bc81363fa6acdbf007e8c3aff28b4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 22:30:00 -0700 Subject: Code block: adjust logging levels --- bot/cogs/codeblock/cog.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 3c119814f..74f122936 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -110,7 +110,7 @@ class CodeBlockCog(Cog, name="Code Block"): The embed will be deleted automatically after 5 minutes. """ - log.trace("Sending an embed with code block formatting instructions.") + log.info(f"Sending code block formatting instructions for message {message.id}.") embed = self.create_embed(instructions) bot_message = await message.channel.send(f"Hey {message.author.mention}!", embed=embed) @@ -155,7 +155,7 @@ class CodeBlockCog(Cog, name="Code Block"): await self.send_instructions(msg, instructions) if msg.channel.id not in self.channel_whitelist: - log.trace(f"Adding #{msg.channel} to the channel cooldowns.") + log.debug(f"Adding #{msg.channel} to the channel cooldowns.") self.channel_cooldowns[msg.channel.id] = time.time() @Cog.listener() @@ -178,9 +178,9 @@ class CodeBlockCog(Cog, name="Code Block"): return if not instructions: - log.trace("User's incorrect code block has been fixed. Removing instructions message.") + log.info("User's incorrect code block has been fixed. Removing instructions message.") await bot_message.delete() del self.codeblock_message_ids[payload.message_id] else: - log.trace("Message edited but still has invalid code blocks; editing the instructions.") + log.info("Message edited but still has invalid code blocks; editing the instructions.") await bot_message.edit(embed=self.create_embed(instructions)) -- cgit v1.2.3 From a2cac1da6ae309fc8c77a019336348fb236f1bdb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 8 May 2020 17:32:34 -0700 Subject: Create a utility function to count lines in a string --- bot/cogs/codeblock/cog.py | 3 ++- bot/cogs/codeblock/parsing.py | 4 +++- bot/utils/__init__.py | 8 ++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 74f122936..ecaf51aa0 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -8,6 +8,7 @@ from discord.ext.commands import Bot, Cog from bot.cogs.token_remover import TokenRemover from bot.constants import Channels, DEBUG_MODE +from bot.utils import has_lines from bot.utils.channel import is_help_channel from bot.utils.messages import wait_for_deletion from .instructions import get_instructions @@ -134,7 +135,7 @@ class CodeBlockCog(Cog, name="Code Block"): return ( not message.author.bot and self.is_valid_channel(message.channel) - and len(message.content.split("\n", 3)) > 3 + and has_lines(message.content, 4) and not TokenRemover.find_token_in_message(message) ) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 1bdb3b492..332a1deb0 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -5,6 +5,8 @@ import logging import re from typing import NamedTuple, Optional, Sequence +from bot.utils import has_lines + log = logging.getLogger(__name__) BACKTICK = "`" @@ -82,7 +84,7 @@ def find_code_blocks(message: str) -> Optional[Sequence[CodeBlock]]: if groups["tick"] == BACKTICK and language: log.trace("Message has a valid code block with a language; returning None.") return None - elif len(groups["code"].split("\n", 3)) > 3: + elif has_lines(groups["code"], 4): code_block = CodeBlock(groups["code"], language, groups["tick"]) code_blocks.append(code_block) else: diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 5a6e1811b..4a02dc802 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -13,6 +13,14 @@ class CogABCMeta(CogMeta, ABCMeta): pass +def has_lines(string: str, count: int) -> bool: + """Return True if `string` has at least `count` lines.""" + split = string.split("\n", count - 1) + + # Make sure the last part isn't empty, which would happen if there was a final newline. + return split[-1] and len(split) == count + + def pad_base64(data: str) -> str: """Return base64 `data` with padding characters to ensure its length is a multiple of 4.""" return data + "=" * (-len(data) % 4) -- cgit v1.2.3 From 99a1734e8c6ace3e7a6418882f8dae40a3877534 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 8 May 2020 17:43:21 -0700 Subject: Code block: add configurable variables --- bot/cogs/codeblock/cog.py | 29 +++++++++++------------------ bot/cogs/codeblock/parsing.py | 3 ++- bot/constants.py | 9 +++++++++ config-default.yml | 21 +++++++++++++++++++-- 4 files changed, 41 insertions(+), 21 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index ecaf51aa0..e3917751b 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -6,8 +6,8 @@ import discord from discord import Message, RawMessageUpdateEvent from discord.ext.commands import Bot, Cog +from bot import constants from bot.cogs.token_remover import TokenRemover -from bot.constants import Channels, DEBUG_MODE from bot.utils import has_lines from bot.utils.channel import is_help_channel from bot.utils.messages import wait_for_deletion @@ -33,8 +33,7 @@ class CodeBlockCog(Cog, name="Code Block"): 1. Spaces before the language 2. No newline immediately following the language - Messages with 3 or fewer lines overall are ignored. Each code block is subject to this threshold - as well i.e. the text between the ticks must be greater than 3 lines. Detecting multiple code + Messages or code blocks must meet a minimum line count to be detected. Detecting multiple code blocks is supported. However, if at least one code block is correct, then instructions will not be sent even if others are incorrect. When multiple incorrect code blocks are found, only the first one is used as the basis for the instructions sent. @@ -45,23 +44,17 @@ class CodeBlockCog(Cog, name="Code Block"): show what is still incorrect after the user's edit. The embed can be manually deleted with a reaction. Otherwise, it will automatically be removed after 5 minutes. - The cog only detects messages in whitelisted channels. Channels may also have a 300-second - cooldown on the instructions being sent. See `__init__` for which channels are whitelisted or - have cooldowns enabled. Note that all help channels are also whitelisted with cooldowns enabled. + The cog only detects messages in whitelisted channels. Channels may also have a cooldown on the + instructions being sent. Note all help channels are also whitelisted with cooldowns enabled. + + For configurable parameters, see the `code_block` section in config-default.py. """ def __init__(self, bot: Bot): self.bot = bot # Stores allowed channels plus epoch times since the last instructional messages sent. - self.channel_cooldowns = { - Channels.python_discussion: 0, - } - - # These channels will also work, but will not be subject to a cooldown. - self.channel_whitelist = ( - Channels.bot_commands, - ) + self.channel_cooldowns = {channel: 0.0 for channel in constants.CodeBlock.cooldown_channels} # Maps users' messages to the messages the bot sent with instructions. self.codeblock_message_ids = {} @@ -102,7 +95,7 @@ class CodeBlockCog(Cog, name="Code Block"): return ( is_help_channel(channel) or channel.id in self.channel_cooldowns - or channel.id in self.channel_whitelist + or channel.id in constants.CodeBlock.channel_whitelist ) async def send_instructions(self, message: discord.Message, instructions: str) -> None: @@ -135,7 +128,7 @@ class CodeBlockCog(Cog, name="Code Block"): return ( not message.author.bot and self.is_valid_channel(message.channel) - and has_lines(message.content, 4) + and has_lines(message.content, constants.CodeBlock.minimum_lines) and not TokenRemover.find_token_in_message(message) ) @@ -147,7 +140,7 @@ class CodeBlockCog(Cog, name="Code Block"): return # When debugging, ignore cooldowns. - if self.is_on_cooldown(msg.channel) and not DEBUG_MODE: + if self.is_on_cooldown(msg.channel) and not constants.DEBUG_MODE: log.trace(f"Skipping code block detection of {msg.id}: #{msg.channel} is on cooldown.") return @@ -155,7 +148,7 @@ class CodeBlockCog(Cog, name="Code Block"): if instructions: await self.send_instructions(msg, instructions) - if msg.channel.id not in self.channel_whitelist: + if msg.channel.id not in constants.CodeBlock.channel_whitelist: log.debug(f"Adding #{msg.channel} to the channel cooldowns.") self.channel_cooldowns[msg.channel.id] = time.time() diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 332a1deb0..89f8111fc 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -5,6 +5,7 @@ import logging import re from typing import NamedTuple, Optional, Sequence +from bot import constants from bot.utils import has_lines log = logging.getLogger(__name__) @@ -84,7 +85,7 @@ def find_code_blocks(message: str) -> Optional[Sequence[CodeBlock]]: if groups["tick"] == BACKTICK and language: log.trace("Message has a valid code block with a language; returning None.") return None - elif has_lines(groups["code"], 4): + elif has_lines(groups["code"], constants.CodeBlock.minimum_lines): code_block = CodeBlock(groups["code"], language, groups["tick"]) code_blocks.append(code_block) else: diff --git a/bot/constants.py b/bot/constants.py index 470221369..6c9654e89 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -540,6 +540,15 @@ class BigBrother(metaclass=YAMLGetter): header_message_limit: int +class CodeBlock(metaclass=YAMLGetter): + section = 'code_block' + + channel_whitelist: List[int] + cooldown_channels: List[int] + cooldown_seconds: int + minimum_lines: int + + class Free(metaclass=YAMLGetter): section = 'free' diff --git a/config-default.yml b/config-default.yml index 3388e5f78..845a20979 100644 --- a/config-default.yml +++ b/config-default.yml @@ -137,8 +137,8 @@ guild: dev_log: &DEV_LOG 622895325144940554 # Discussion - meta: 429409067623251969 - python_discussion: 267624335836053506 + meta: 429409067623251969 + python_discussion: &PY_DISCUSSION 267624335836053506 # Python Help: Available how_to_get_help: 704250143020417084 @@ -522,6 +522,23 @@ big_brother: header_message_limit: 15 +code_block: + # The channels in which code blocks will be detected. They are not subject to a cooldown. + channel_whitelist: + - *BOT_CMD + + # The channels which will be affected by a cooldown. These channels are also whitelisted. + cooldown_channels: + - *PY_DISCUSSION + + # Sending instructions triggers a cooldown on a per-channel basis. + # More instruction messages will not be sent in the same channel until the cooldown has elapsed. + cooldown_seconds: 300 + + # The minimum amount of lines a message or code block must have for instructions to be sent. + minimum_lines: 4 + + free: # Seconds to elapse for a channel # to be considered inactive. -- cgit v1.2.3 From 39dc3cd229888acac2782237db4b9389c0788478 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 13 Jun 2020 21:52:20 +0200 Subject: Incidents tests: move `mock_404` into module namespace This will be useful for others tests as well. --- tests/bot/cogs/moderation/test_incidents.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 7500235cf..e51bda114 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -14,6 +14,12 @@ class MockSignal(enum.Enum): B = "B" +mock_404 = discord.NotFound( + response=MagicMock(aiohttp.ClientResponse), # Mock the erroneous response + message="Not found", +) + + @patch("bot.constants.Channels.incidents", 123) class TestIsIncident(unittest.TestCase): """ @@ -165,11 +171,6 @@ class TestArchive(TestIncidents): Implicitly, this also tests that the error is handled internally and doesn't propagate out of the method, which is just as important. """ - mock_404 = discord.NotFound( - response=MagicMock(aiohttp.ClientResponse), # Mock the erroneous response - message="Webhook not found", - ) - self.cog_instance.bot.fetch_webhook = AsyncMock(side_effect=mock_404) self.assertFalse(await self.cog_instance.archive(incident=MockMessage(), outcome=MagicMock())) -- cgit v1.2.3 From 8ed5cc7ef5e38885a8e439602b59e56449d3633c Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 13 Jun 2020 21:52:34 +0200 Subject: Incidents tests: write tests for `resolve_message` --- tests/bot/cogs/moderation/test_incidents.py | 56 +++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index e51bda114..b3beec3ab 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -212,3 +212,59 @@ class TestArchive(TestIncidents): # Finally check that the method returned True self.assertTrue(archive_return) + + +class TestResolveMessage(TestIncidents): + """Tests for the `Incidents.resolve_message` coroutine.""" + + async def test_resolve_message_pass_message_id(self): + """Method will call `_get_message` with the passed `message_id`.""" + await self.cog_instance.resolve_message(123) + self.cog_instance.bot._connection._get_message.assert_called_once_with(123) + + async def test_resolve_message_in_cache(self): + """ + No API call is made if the queried message exists in the cache. + + We mock the `_get_message` return value regardless of input. Whether it finds the message + internally is considered d.py's responsibility, not ours. + """ + cached_message = MockMessage(id=123) + self.cog_instance.bot._connection._get_message = MagicMock(return_value=cached_message) + + return_value = await self.cog_instance.resolve_message(123) + + self.assertIs(return_value, cached_message) + self.cog_instance.bot.get_channel.assert_not_called() # The `fetch_message` line was never hit + + async def test_resolve_message_not_in_cache(self): + """ + The message is retrieved from the API if it isn't cached. + + This is desired behaviour for messages which exist, but were sent before the bot's + current session. + """ + self.cog_instance.bot._connection._get_message = MagicMock(return_value=None) # Cache returns None + + # API returns our message + uncached_message = MockMessage() + fetch_message = AsyncMock(return_value=uncached_message) + self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(fetch_message=fetch_message)) + + retrieved_message = await self.cog_instance.resolve_message(123) + self.assertIs(retrieved_message, uncached_message) + + async def test_resolve_message_doesnt_exist(self): + """ + If the API returns a 404, the function handles it gracefully and returns None. + + This is an edge-case happening with racing events - event A will relay the message + to the archive and delete the original. Once event B acquires the `event_lock`, + it will not find the message in the cache, and will ask the API. + """ + self.cog_instance.bot._connection._get_message = MagicMock(return_value=None) # Cache returns None + + fetch_message = AsyncMock(side_effect=mock_404) + self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(fetch_message=fetch_message)) + + self.assertIsNone(await self.cog_instance.resolve_message(123)) -- cgit v1.2.3 From 7c43eff17a07471799174c0a0e8813b9f58d2ab5 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 13 Jun 2020 22:09:44 +0200 Subject: Incidents: log error on non-404 response We do not wish to log 404 exceptions as those are expected, however, if something else goes wrong, we shouldn't silence it. This also removes the explicit None return as it only adds syntax noise. --- bot/cogs/moderation/incidents.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 151584d38..16286bdab 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -274,9 +274,10 @@ class Incidents(Cog): log.debug("Message not found, attempting to fetch") try: message = await self.bot.get_channel(Channels.incidents).fetch_message(message_id) + except discord.NotFound: + log.debug("Message doesn't exist, it was likely already relayed") except Exception as exc: - log.debug(f"Failed to fetch message: {exc}") - return None + log.exception("Failed to fetch message!", exc_info=exc) else: log.debug("Message fetched successfully!") return message -- cgit v1.2.3 From bbedcb377c4c31973f43f076c3f62646f25733b3 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 13 Jun 2020 22:15:38 +0200 Subject: Incidents tests: test non-404 error response --- tests/bot/cogs/moderation/test_incidents.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index b3beec3ab..cbeb3342c 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -1,4 +1,5 @@ import enum +import logging import unittest from unittest.mock import AsyncMock, MagicMock, call, patch @@ -268,3 +269,22 @@ class TestResolveMessage(TestIncidents): self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(fetch_message=fetch_message)) self.assertIsNone(await self.cog_instance.resolve_message(123)) + + async def test_resolve_message_fetch_fails(self): + """ + Non-404 errors are handled, logged & None is returned. + + In contrast with a 404, this should make an error-level log. We assert that at least + one such log was made - we do not make any assertions about the log's message. + """ + self.cog_instance.bot._connection._get_message = MagicMock(return_value=None) # Cache returns None + + arbitrary_error = discord.HTTPException( + response=MagicMock(aiohttp.ClientResponse), + message="Arbitrary error", + ) + fetch_message = AsyncMock(side_effect=arbitrary_error) + self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(fetch_message=fetch_message)) + + with self.assertLogs(logger=incidents.log, level=logging.ERROR): + self.assertIsNone(await self.cog_instance.resolve_message(123)) -- cgit v1.2.3 From 14b7fee42ddf6a2cc75526506ef2028bdc742c9a Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 13 Jun 2020 22:42:58 +0200 Subject: Incidents tests: write tests for `on_message` --- tests/bot/cogs/moderation/test_incidents.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index cbeb3342c..0eb13df70 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -288,3 +288,30 @@ class TestResolveMessage(TestIncidents): with self.assertLogs(logger=incidents.log, level=logging.ERROR): self.assertIsNone(await self.cog_instance.resolve_message(123)) + + +class TestOnMessage(TestIncidents): + """ + Tests for the `Incidents.on_message` listener. + + Notice the decorators mocking the `is_incident` return value. The `is_incidents` + function is tested in `TestIsIncident` - here we do not worry about it. + """ + + @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=True)) + async def test_on_message_incident(self): + """Messages qualifying as incidents are passed to `add_signals`.""" + incident = MockMessage() + + with patch("bot.cogs.moderation.incidents.add_signals", AsyncMock()) as mock_add_signals: + await self.cog_instance.on_message(incident) + + mock_add_signals.assert_called_once_with(incident) + + @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=False)) + async def test_on_message_non_incident(self): + """Messages not qualifying as incidents are ignored.""" + with patch("bot.cogs.moderation.incidents.add_signals", AsyncMock()) as mock_add_signals: + await self.cog_instance.on_message(MockMessage()) + + mock_add_signals.assert_not_called() -- cgit v1.2.3 From 9d35846a67c2bf9ed9e935f8b5e3500ae4b49327 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 13 Jun 2020 23:24:14 +0200 Subject: Incidents tests: write tests for `make_confirmation_task` --- tests/bot/cogs/moderation/test_incidents.py | 35 +++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 0eb13df70..c093afc8a 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -215,6 +215,41 @@ class TestArchive(TestIncidents): self.assertTrue(archive_return) +class TestMakeConfirmationTask(TestIncidents): + """ + Tests for the `Incidents.make_confirmation_task` method. + + Writing tests for this method is difficult, as it mostly just delegates the provided + information elsewhere. There is very little internal logic. Whether our approach + works conceptually is difficult to prove using unit tests. + """ + + def test_make_confirmation_task_check(self): + """ + The internal check will recognize the passed incident. + + This is a little tricky - we first pass a message with a specific `id` in, and then + retrieve the built check from the `call_args` of the `wait_for` method. This relies + on the check being passed as a kwarg. + + Once the check is retrieved, we assert that it gives True for our incident's `id`, + and False for any other. + + If this function begins to fail, first check that `created_check` is being retrieved + correctly. It should be the function that is built locally in the tested method. + """ + self.cog_instance.make_confirmation_task(MockMessage(id=123)) + + self.cog_instance.bot.wait_for.assert_called_once() + created_check = self.cog_instance.bot.wait_for.call_args.kwargs["check"] + + # The `message_id` matches the `id` of our incident + self.assertTrue(created_check(payload=MagicMock(message_id=123))) + + # This `message_id` does not match + self.assertFalse(created_check(payload=MagicMock(message_id=0))) + + class TestResolveMessage(TestIncidents): """Tests for the `Incidents.resolve_message` coroutine.""" -- cgit v1.2.3 From da816921db5295a33d7af918f329e770c03d73a2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 13 May 2020 18:51:31 -0700 Subject: Code block: simplify retrieval of channel ID from payload --- bot/cogs/codeblock/cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index e3917751b..20b86eb24 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -72,7 +72,7 @@ class CodeBlockCog(Cog, name="Code Block"): deleted either manually via a reaction or automatically by a timer. """ log.trace(f"Retrieving instructions message for ID {payload.message_id}") - channel = self.bot.get_channel(int(payload.data.get("channel_id"))) + channel = self.bot.get_channel(payload.channel_id) try: return await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) -- cgit v1.2.3 From e98100fed8b3c62e337a1c0abeeaee30bc08befa Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 3 Jun 2020 12:26:27 -0700 Subject: Code block: add stats * Increment `codeblock_corrections` when instructions are sent * Import our Bot subclass instead of discord.py's --- bot/cogs/codeblock/cog.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 20b86eb24..6032e911c 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -4,9 +4,10 @@ from typing import Optional import discord from discord import Message, RawMessageUpdateEvent -from discord.ext.commands import Bot, Cog +from discord.ext.commands import Cog from bot import constants +from bot.bot import Bot from bot.cogs.token_remover import TokenRemover from bot.utils import has_lines from bot.utils.channel import is_help_channel @@ -114,6 +115,9 @@ class CodeBlockCog(Cog, name="Code Block"): wait_for_deletion(bot_message, user_ids=(message.author.id,), client=self.bot) ) + # Increase amount of codeblock correction in stats + self.bot.stats.incr("codeblock_corrections") + def should_parse(self, message: discord.Message) -> bool: """ Return True if `message` should be parsed. -- cgit v1.2.3 From cb0529b327000a39d0329143fb5c3db2504d0219 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 10 Jun 2020 21:42:26 -0700 Subject: Code block: remove needless f-strings --- bot/cogs/codeblock/instructions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 5c573c2ff..c9db80deb 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -156,17 +156,17 @@ def get_instructions(content: str) -> Optional[str]: return if not blocks: - log.trace(f"No code blocks were found in message.") + log.trace("No code blocks were found in message.") return _get_no_ticks_message(content) else: log.trace("Searching results for a code block with invalid ticks.") block = next((block for block in blocks if block.tick != parsing.BACKTICK), None) if block: - log.trace(f"A code block exists but has invalid ticks.") + log.trace("A code block exists but has invalid ticks.") return _get_bad_ticks_message(block) else: - log.trace(f"A code block exists but is missing a language.") + log.trace("A code block exists but is missing a language.") block = blocks[0] # Check for a bad language first to avoid parsing content into an AST. -- cgit v1.2.3 From d9ed643c41c8cf96ec208d6fc096882fc64c5d15 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 14 Jun 2020 21:06:39 -0700 Subject: ModLog: fix AttributeError in on_member_update `iterable_item_removed` and `iterable_item_added` lack `new_value` and `old_value`. Instead, they just contain the actual value added or removed. The code was incorrectly trying to access old and new values for the iterable changes. The iterable changes are only useful for the role diff, but they aren't even needed for that. The role diff calculation has been refactored to always get the diff rather than doing it only if it sees there has been a change to the `_roles` attribute. To be clear, `_roles` only has IDs, which is why its diff isn't that useful anyway. To use it, the code would have to get the Role objects, which is basically what the `roles` property already does. `_cs_roles` seems to be some Role object cache, but its reliability is unclear. --- bot/cogs/moderation/modlog.py | 42 ++++++++++++++++++++---------------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 41472c64c..02396e1c5 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -452,6 +452,21 @@ class ModLog(Cog, name="ModLog"): channel_id=Channels.mod_log ) + @staticmethod + def get_role_diff(before: t.List[discord.Role], after: t.List[discord.Role]) -> t.List[str]: + """Return a list of strings describing the roles added and removed.""" + changes = [] + before_roles = set(before) + after_roles = set(after) + + for role in (before_roles - after_roles): + changes.append(f"**Role removed:** {role.name} (`{role.id}`)") + + for role in (after_roles - before_roles): + changes.append(f"**Role added:** {role.name} (`{role.id}`)") + + return changes + @Cog.listener() async def on_member_update(self, before: discord.Member, after: discord.Member) -> None: """Log member update event to user log.""" @@ -463,22 +478,18 @@ class ModLog(Cog, name="ModLog"): return diff = DeepDiff(before, after) - changes = [] + changes = self.get_role_diff(before.roles, after.roles) done = [] diff_values = {} diff_values.update(diff.get("values_changed", {})) diff_values.update(diff.get("type_changes", {})) - diff_values.update(diff.get("iterable_item_removed", {})) - diff_values.update(diff.get("iterable_item_added", {})) diff_user = DeepDiff(before._user, after._user) diff_values.update(diff_user.get("values_changed", {})) diff_values.update(diff_user.get("type_changes", {})) - diff_values.update(diff_user.get("iterable_item_removed", {})) - diff_values.update(diff_user.get("iterable_item_added", {})) for key, value in diff_values.items(): if not key: # Not sure why, but it happens @@ -495,24 +506,11 @@ class ModLog(Cog, name="ModLog"): if key in done or key in MEMBER_CHANGES_SUPPRESSED: continue - if key == "_roles": - new_roles = after.roles - old_roles = before.roles + new = value.get("new_value") + old = value.get("old_value") - for role in old_roles: - if role not in new_roles: - changes.append(f"**Role removed:** {role.name} (`{role.id}`)") - - for role in new_roles: - if role not in old_roles: - changes.append(f"**Role added:** {role.name} (`{role.id}`)") - - else: - new = value.get("new_value") - old = value.get("old_value") - - if new and old: - changes.append(f"**{key.title()}:** `{old}` **→** `{new}`") + if new and old: + changes.append(f"**{key.title()}:** `{old}` **→** `{new}`") done.append(key) -- cgit v1.2.3 From 9133c4a7b79020d507b9cecbb9ce6d957b52fd9d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 14 Jun 2020 21:57:53 -0700 Subject: ModLog: remove user diff in on_member_update The correct event for user changes is on_user_update, so this code does nothing in the on_member_update event. --- bot/cogs/moderation/modlog.py | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 02396e1c5..703da4ee7 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -24,7 +24,7 @@ GUILD_CHANNEL = t.Union[discord.CategoryChannel, discord.TextChannel, discord.Vo CHANNEL_CHANGES_UNSUPPORTED = ("permissions",) CHANNEL_CHANGES_SUPPRESSED = ("_overwrites", "position") -MEMBER_CHANGES_SUPPRESSED = ("status", "activities", "_client_status", "nick") +MEMBER_CHANGES_SUPPRESSED = ("status", "activities", "_client_status") ROLE_CHANGES_UNSUPPORTED = ("colour", "permissions") VOICE_STATE_ATTRIBUTES = { @@ -486,11 +486,6 @@ class ModLog(Cog, name="ModLog"): diff_values.update(diff.get("values_changed", {})) diff_values.update(diff.get("type_changes", {})) - diff_user = DeepDiff(before._user, after._user) - - diff_values.update(diff_user.get("values_changed", {})) - diff_values.update(diff_user.get("type_changes", {})) - for key, value in diff_values.items(): if not key: # Not sure why, but it happens continue @@ -514,21 +509,6 @@ class ModLog(Cog, name="ModLog"): done.append(key) - if before.name != after.name: - changes.append( - f"**Username:** `{before.name}` **→** `{after.name}`" - ) - - if before.discriminator != after.discriminator: - changes.append( - f"**Discriminator:** `{before.discriminator}` **→** `{after.discriminator}`" - ) - - if before.display_name != after.display_name: - changes.append( - f"**Display name:** `{before.display_name}` **→** `{after.display_name}`" - ) - if not changes: return -- cgit v1.2.3 From 17858e4d65d5592d1da6178cb80415de615f21ab Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 14 Jun 2020 22:05:18 -0700 Subject: ModLog: fix excluded None values in on_member_update This was preventing diffs for added nicknames from showing, among other things. --- bot/cogs/moderation/modlog.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 703da4ee7..163721e1c 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -504,8 +504,7 @@ class ModLog(Cog, name="ModLog"): new = value.get("new_value") old = value.get("old_value") - if new and old: - changes.append(f"**{key.title()}:** `{old}` **→** `{new}`") + changes.append(f"**{key.title()}:** `{old}` **→** `{new}`") done.append(key) -- cgit v1.2.3 From 35fc846e671192199bde7e98e43b2ac21513f629 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 14 Jun 2020 21:16:02 -0700 Subject: ModLog: refactor on_member_update * Exclude all sequences/mapping types rather than excluding by name * Replace MEMBER_CHANGES_SUPPRESSED with excludes as DeepDiff args * Don't keep track of "done" attributes - there shouldn't be dupes --- bot/cogs/moderation/modlog.py | 38 +++++++++++++++----------------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 163721e1c..bd805f590 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -24,7 +24,6 @@ GUILD_CHANNEL = t.Union[discord.CategoryChannel, discord.TextChannel, discord.Vo CHANNEL_CHANGES_UNSUPPORTED = ("permissions",) CHANNEL_CHANGES_SUPPRESSED = ("_overwrites", "position") -MEMBER_CHANGES_SUPPRESSED = ("status", "activities", "_client_status") ROLE_CHANGES_UNSUPPORTED = ("colour", "permissions") VOICE_STATE_ATTRIBUTES = { @@ -477,36 +476,27 @@ class ModLog(Cog, name="ModLog"): self._ignored[Event.member_update].remove(before.id) return - diff = DeepDiff(before, after) changes = self.get_role_diff(before.roles, after.roles) - done = [] - diff_values = {} + # The regex is a simple way to exclude all sequence and mapping types. + diff = DeepDiff(before, after, exclude_regex_paths=r".*\[.*") - diff_values.update(diff.get("values_changed", {})) - diff_values.update(diff.get("type_changes", {})) + # A type change seems to always take precedent over a value change. Furthermore, it will + # include the value change along with the type change anyway. Therefore, it's OK to + # "overwrite" values_changed; in practice there will never even be anything to overwrite. + diff_values = {**diff.get("values_changed", {}), **diff.get("type_changes", {})} - for key, value in diff_values.items(): - if not key: # Not sure why, but it happens + for attr, value in diff_values.items(): + if not attr: # Not sure why, but it happens. continue - key = key[5:] # Remove "root." prefix - - if "[" in key: - key = key.split("[", 1)[0] - - if "." in key: - key = key.split(".", 1)[0] - - if key in done or key in MEMBER_CHANGES_SUPPRESSED: - continue + attr = attr[len("root."):] # Remove "root." prefix. + attr = attr.replace("_", " ").replace(".", " ").capitalize() new = value.get("new_value") old = value.get("old_value") - changes.append(f"**{key.title()}:** `{old}` **→** `{new}`") - - done.append(key) + changes.append(f"**{attr}:** `{old}` **→** `{new}`") if not changes: return @@ -520,8 +510,10 @@ class ModLog(Cog, name="ModLog"): message = f"**{member_str}** (`{after.id}`)\n{message}" await self.send_log_message( - Icons.user_update, Colour.blurple(), - "Member updated", message, + icon_url=Icons.user_update, + colour=Colour.blurple(), + title="Member updated", + text=message, thumbnail=after.avatar_url_as(static_format="png"), channel_id=Channels.user_log ) -- cgit v1.2.3 From b2972e0f816c60395517412011e312a3040491a0 Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 16 Jun 2020 13:12:58 -0700 Subject: Use int literal instead of len for slice Co-authored-by: Kieran Siek --- bot/cogs/moderation/modlog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index bd805f590..ffbb87bbe 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -490,7 +490,7 @@ class ModLog(Cog, name="ModLog"): if not attr: # Not sure why, but it happens. continue - attr = attr[len("root."):] # Remove "root." prefix. + attr = attr[5:] # Remove "root." prefix. attr = attr.replace("_", " ").replace(".", " ").capitalize() new = value.get("new_value") -- cgit v1.2.3 From 51d681654d9a9acc71763edffcea0d5eb1ef1b29 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 18 Jun 2020 08:13:15 +0300 Subject: Source: Simplify missing tag cog handling --- bot/cogs/source.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 32a78a0c0..d59371c6e 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -29,10 +29,7 @@ class SourceConverter(commands.Converter): tags_cog = ctx.bot.get_cog("Tags") - if not tags_cog: - await ctx.send("Unable to get `Tags` cog.") - return commands.ExtensionNotLoaded("bot.cogs.tags") - elif argument.lower() in tags_cog._cache: + if tags_cog and argument.lower() in tags_cog._cache: return argument.lower() raise commands.BadArgument(f"Unable to convert `{argument}` to valid command, tag, or Cog.") @@ -47,10 +44,6 @@ class BotSource(commands.Cog): @commands.command(name="source", aliases=("src",)) async def source_command(self, ctx: commands.Context, *, source_item: SourceConverter = None) -> None: """Display information and a GitHub link to the source code of a command, tag, or cog.""" - # When we have problem to get Tags cog, exit early - if isinstance(source_item, commands.ExtensionNotLoaded): - return - if not source_item: embed = Embed(title="Bot's GitHub Repository") embed.add_field(name="Repository", value=f"[Go to GitHub]({URLs.github_bot_repo})") -- cgit v1.2.3 From 31f53259fd73503399b904e5a7075ceeded4c742 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 18 Jun 2020 08:58:18 +0300 Subject: Source: Split handling tag and other source items file location --- bot/cogs/source.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index d59371c6e..2ca852af3 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -82,7 +82,12 @@ class BotSource(commands.Cog): first_line_no = None lines_extension = "" - file_location = Path(filename).relative_to("/bot/") + # Handle tag file location differently than others to avoid errors in some cases + if not first_line_no: + file_location = Path(filename).relative_to("/bot/") + else: + file_location = Path(filename).relative_to(Path.cwd()).as_posix() + url = f"{URLs.github_bot_repo}/blob/master/{file_location}{lines_extension}" return url, file_location, first_line_no or None -- cgit v1.2.3 From caa421054669f886750a54fb2fdbea3315c58a58 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 18 Jun 2020 09:07:47 +0300 Subject: Source: Exclude `tag` from error message when tags cog not loaded --- bot/cogs/source.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 2ca852af3..223552651 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -28,11 +28,14 @@ class SourceConverter(commands.Converter): return cmd tags_cog = ctx.bot.get_cog("Tags") + show_tag = True - if tags_cog and argument.lower() in tags_cog._cache: + if not tags_cog: + show_tag = False + elif argument.lower() in tags_cog._cache: return argument.lower() - raise commands.BadArgument(f"Unable to convert `{argument}` to valid command, tag, or Cog.") + raise commands.BadArgument(f"Unable to convert `{argument}` to valid command{', tag,' if show_tag else ''} or Cog.") class BotSource(commands.Cog): -- cgit v1.2.3 From 4c9a62f93fd7b92051dd40e4d799236d65e154ab Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 18 Jun 2020 09:10:55 +0300 Subject: Source: Split to multiple lines to fix too long line on error raising --- bot/cogs/source.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 223552651..f1db745cd 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -35,7 +35,9 @@ class SourceConverter(commands.Converter): elif argument.lower() in tags_cog._cache: return argument.lower() - raise commands.BadArgument(f"Unable to convert `{argument}` to valid command{', tag,' if show_tag else ''} or Cog.") + raise commands.BadArgument( + f"Unable to convert `{argument}` to valid command{', tag,' if show_tag else ''} or Cog." + ) class BotSource(commands.Cog): -- cgit v1.2.3 From 40e00ff17465fc5a5fe6b46487bfea37655cd7b9 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 18 Jun 2020 19:33:59 +0200 Subject: Incidents tests: write tests for `process_event` This also breaks the helpers import statement into a vertical list, as the amount of imports has grown too much. I still believe that this is a preferred alternative to accessing the helpers via module namespace, as we use them a lot, and the added visual noise would be annoying to read - their names are already descriptive enough. --- tests/bot/cogs/moderation/test_incidents.py | 102 +++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index c093afc8a..6158d5d20 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -1,3 +1,4 @@ +import asyncio import enum import logging import unittest @@ -7,7 +8,16 @@ import aiohttp import discord from bot.cogs.moderation import Incidents, incidents -from tests.helpers import MockAsyncWebhook, MockBot, MockMessage, MockReaction, MockTextChannel, MockUser +from tests.helpers import ( + MockAsyncWebhook, + MockBot, + MockMember, + MockMessage, + MockReaction, + MockRole, + MockTextChannel, + MockUser, +) class MockSignal(enum.Enum): @@ -250,6 +260,96 @@ class TestMakeConfirmationTask(TestIncidents): self.assertFalse(created_check(payload=MagicMock(message_id=0))) +@patch("bot.cogs.moderation.incidents.ALLOWED_ROLES", {1, 2}) +@patch("bot.cogs.moderation.incidents.Incidents.make_confirmation_task", AsyncMock()) # Generic awaitable +class TestProcessEvent(TestIncidents): + """Tests for the `Incidents.process_event` coroutine.""" + + @patch("bot.cogs.moderation.incidents.ALLOWED_ROLES", {1, 2}) + async def test_process_event_bad_role(self): + """The reaction is removed when the author lacks all allowed roles.""" + incident = MockMessage() + member = MockMember(roles=[MockRole(id=0)]) # Must have role 1 or 2 + + await self.cog_instance.process_event("reaction", incident, member) + incident.remove_reaction.assert_called_once_with("reaction", member) + + async def test_process_event_bad_emoji(self): + """ + The reaction is removed when an invalid emoji is used. + + This requires that we pass in a `member` with valid roles, as we need the role check + to succeed. + """ + incident = MockMessage() + member = MockMember(roles=[MockRole(id=1)]) # Member has allowed role + + await self.cog_instance.process_event("invalid_signal", incident, member) + incident.remove_reaction.assert_called_once_with("invalid_signal", member) + + async def test_process_event_no_archive_on_investigating(self): + """Message is not archived on `Signal.INVESTIGATING`.""" + with patch("bot.cogs.moderation.incidents.Incidents.archive", AsyncMock()) as mocked_archive: + await self.cog_instance.process_event( + reaction=incidents.Signal.INVESTIGATING.value, + incident=MockMessage(), + member=MockMember(roles=[MockRole(id=1)]), + ) + + mocked_archive.assert_not_called() + + async def test_process_event_no_delete_if_archive_fails(self): + """ + Original message is not deleted when `Incidents.archive` returns False. + + This is the way of signaling that the relay failed, and we should not remove the original, + as that would result in losing the incident record. + """ + incident = MockMessage() + + with patch("bot.cogs.moderation.incidents.Incidents.archive", AsyncMock(return_value=False)): + await self.cog_instance.process_event( + reaction=incidents.Signal.ACTIONED.value, + incident=incident, + member=MockMember(roles=[MockRole(id=1)]) + ) + + incident.delete.assert_not_called() + + async def test_process_event_confirmation_task_is_awaited(self): + """Task given by `Incidents.make_confirmation_task` is awaited before method exits.""" + mock_task = AsyncMock() + + with patch("bot.cogs.moderation.incidents.Incidents.make_confirmation_task", mock_task): + await self.cog_instance.process_event( + reaction=incidents.Signal.ACTIONED.value, + incident=MockMessage(), + member=MockMember(roles=[MockRole(id=1)]) + ) + + mock_task.assert_awaited() + + async def test_process_event_confirmation_task_timeout_is_handled(self): + """ + Confirmation task `asyncio.TimeoutError` is handled gracefully. + + We have `make_confirmation_task` return a mock with a side effect, and then catch the + exception should it propagate out of `process_event`. This is so that we can then manually + fail the test with a more informative message than just the plain traceback. + """ + mock_task = AsyncMock(side_effect=asyncio.TimeoutError()) + + try: + with patch("bot.cogs.moderation.incidents.Incidents.make_confirmation_task", mock_task): + await self.cog_instance.process_event( + reaction=incidents.Signal.ACTIONED.value, + incident=MockMessage(), + member=MockMember(roles=[MockRole(id=1)]) + ) + except asyncio.TimeoutError: + self.fail("TimeoutError was not handled gracefully, and propagated out of `process_event`!") + + class TestResolveMessage(TestIncidents): """Tests for the `Incidents.resolve_message` coroutine.""" -- cgit v1.2.3 From ed4097629601704f0c65fc40cceb5fd6757d4779 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 19 Jun 2020 14:32:31 +0200 Subject: Incidents tests: add helper for mocking async for-loops See the docstring. This does not make the ambition to be powerful enough to be included in `tests.helpers`, and is only intended for local purposes. --- tests/bot/cogs/moderation/test_incidents.py | 37 +++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 6158d5d20..7fa8847ef 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -1,6 +1,7 @@ import asyncio import enum import logging +import typing as t import unittest from unittest.mock import AsyncMock, MagicMock, call, patch @@ -20,6 +21,42 @@ from tests.helpers import ( ) +class MockAsyncIterable: + """ + Helper for mocking asynchronous for loops. + + It does not appear that the `unittest` library currently provides anything that would + allow us to simply mock an async iterator, such as `discord.TextChannel.history`. + + We therefore write our own helper to wrap a regular synchronous iterable, and feed + its values via `__anext__` rather than `__next__`. + + This class was written for the purposes of testing the `Incidents` cog - it may not + be generic enough to be placed in the `tests.helpers` module. + """ + + def __init__(self, messages: t.Iterable): + """Take a sync iterable to be wrapped.""" + self.iter_messages = iter(messages) + + def __aiter__(self): + """Return `self` as we provide the `__anext__` method.""" + return self + + async def __anext__(self): + """ + Feed the next item, or raise `StopAsyncIteration`. + + Since we're wrapping a sync iterator, it will communicate that it has been depleted + by raising a `StopIteration`. The `async for` construct does not expect it, and we + therefore need to substitute it for the appropriate exception type. + """ + try: + return next(self.iter_messages) + except StopIteration: + raise StopAsyncIteration + + class MockSignal(enum.Enum): A = "A" B = "B" -- cgit v1.2.3 From d93ed5d801c08b7fb084427906e7ac484ac3563f Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 19 Jun 2020 14:37:44 +0200 Subject: Incidents tests: write tests for `crawl_incidents` --- tests/bot/cogs/moderation/test_incidents.py | 58 +++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 7fa8847ef..4e6dfd5f7 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -209,6 +209,64 @@ class TestIncidents(unittest.IsolatedAsyncioTestCase): self.cog_instance = Incidents(MockBot()) +@patch("asyncio.sleep", AsyncMock()) # Prevent the coro from sleeping to speed up the test +class TestCrawlIncidents(TestIncidents): + """ + Tests for the `Incidents.crawl_incidents` coroutine. + + Apart from `test_crawl_incidents_waits_until_cache_ready`, all tests in this class + will patch the return values of `is_incident` and `has_signal` and then observe + whether the `AsyncMock` for `add_signals` was awaited or not. + + The `add_signals` mock is added by each test separately to ensure it is clean (has not + been awaited by another test yet). The mock can be reset, but this appears to be the + cleaner way. + + For each test, we inject a mock channel with a history of 1 message only (see: `setUp`). + """ + + def setUp(self): + """For each test, ensure `bot.get_channel` returns a channel with 1 arbitrary message.""" + super().setUp() # First ensure we get `cog_instance` from parent + + incidents_history = MagicMock(return_value=MockAsyncIterable([MockMessage()])) + self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(history=incidents_history)) + + async def test_crawl_incidents_waits_until_cache_ready(self): + """ + The coroutine will await the `wait_until_guild_available` event. + + Since this task is schedule in the `__init__`, it is critical that it waits for the + cache to be ready, so that it can safely get the #incidents channel. + """ + await self.cog_instance.crawl_incidents() + self.cog_instance.bot.wait_until_guild_available.assert_awaited() + + @patch("bot.cogs.moderation.incidents.add_signals", AsyncMock()) + @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=False)) # Message doesn't qualify + @patch("bot.cogs.moderation.incidents.has_signals", MagicMock(return_value=False)) + async def test_crawl_incidents_noop_if_is_not_incident(self): + """Signals are not added for a non-incident message.""" + await self.cog_instance.crawl_incidents() + incidents.add_signals.assert_not_awaited() + + @patch("bot.cogs.moderation.incidents.add_signals", AsyncMock()) + @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=True)) # Message qualifies + @patch("bot.cogs.moderation.incidents.has_signals", MagicMock(return_value=True)) # But already has signals + async def test_crawl_incidents_noop_if_message_already_has_signals(self): + """Signals are not added for messages which already have them.""" + await self.cog_instance.crawl_incidents() + incidents.add_signals.assert_not_awaited() + + @patch("bot.cogs.moderation.incidents.add_signals", AsyncMock()) + @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=True)) # Message qualifies + @patch("bot.cogs.moderation.incidents.has_signals", MagicMock(return_value=False)) # And doesn't have signals + async def test_crawl_incidents_add_signals_called(self): + """Message has signals added as it does not have them yet and qualifies as an incident.""" + await self.cog_instance.crawl_incidents() + incidents.add_signals.assert_awaited_once() + + class TestArchive(TestIncidents): """Tests for the `Incidents.archive` coroutine.""" -- cgit v1.2.3 From 9a58b45cad51c961ad34fa9de9aaa060446c54fd Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 19 Jun 2020 16:57:15 +0200 Subject: Incidents tests: write tests for `on_raw_reaction_add` --- tests/bot/cogs/moderation/test_incidents.py | 128 ++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 4e6dfd5f7..55b15ec9e 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -520,6 +520,134 @@ class TestResolveMessage(TestIncidents): self.assertIsNone(await self.cog_instance.resolve_message(123)) +@patch("bot.constants.Channels.incidents", 123) +class TestOnRawReactionAdd(TestIncidents): + """ + Tests for the `Incidents.on_raw_reaction_add` listener. + + Writing tests for this listener comes with additional complexity due to the listener + awaiting the `crawl_task` task. See `asyncSetUp` for further details, which attempts + to make unit testing this function possible. + """ + + def setUp(self): + """ + Prepare & assign `payload` attribute. + + This attribute represents an *ideal* payload which will not be rejected by the + listener. As each test will receive a fresh instance, it can be mutated to + observe how the listener's behaviour changes with different attributes on + the passed payload. + """ + super().setUp() # Ensure `cog_instance` is assigned + + self.payload = MagicMock( + discord.RawReactionActionEvent, + channel_id=123, # Patched at class level + message_id=456, + member=MockMember(bot=False), + emoji="reaction", + ) + + async def asyncSetUp(self): # noqa: N802 + """ + Prepare an empty task and assign it as `crawl_task`. + + It appears that the `unittest` framework does not provide anything for mocking + asyncio tasks. An `AsyncMock` instance can be called and then awaited, however, + it does not provide the `done` method or any other parts of the `asyncio.Task` + interface. + + Although we do not need to make any assertions about the task itself while + testing the listener, the code will still await it and call the `done` method, + and so we must inject something that will not fail on either action. + + Note that this is done in an `asyncSetUp`, which runs after `setUp`. + The justification is that creating an actual task requires the event + loop to be ready, which is not the case in the `setUp`. + """ + mock_task = asyncio.create_task(AsyncMock()()) # Mock async func, then a coro + self.cog_instance.crawl_task = mock_task + + async def test_on_raw_reaction_add_wrong_channel(self): + """ + Events outside of #incidents will be ignored. + + We check this by asserting that `resolve_message` was never queried. + """ + self.payload.channel_id = 0 + self.cog_instance.resolve_message = AsyncMock() + + await self.cog_instance.on_raw_reaction_add(self.payload) + self.cog_instance.resolve_message.assert_not_called() + + async def test_on_raw_reaction_add_user_is_bot(self): + """ + Events dispatched by bot accounts will be ignored. + + We check this by asserting that `resolve_message` was never queried. + """ + self.payload.member = MockMember(bot=True) + self.cog_instance.resolve_message = AsyncMock() + + await self.cog_instance.on_raw_reaction_add(self.payload) + self.cog_instance.resolve_message.assert_not_called() + + async def test_on_raw_reaction_add_message_doesnt_exist(self): + """ + Listener gracefully handles the case where `resolve_message` gives None. + + We check this by asserting that `process_event` was never called. + """ + self.cog_instance.process_event = AsyncMock() + self.cog_instance.resolve_message = AsyncMock(return_value=None) + + await self.cog_instance.on_raw_reaction_add(self.payload) + self.cog_instance.process_event.assert_not_called() + + async def test_on_raw_reaction_add_message_is_not_an_incident(self): + """ + The event won't be processed if the related message is not an incident. + + This is an edge-case that can happen if someone manually leaves a reaction + on a pinned message, or a comment. + + We check this by asserting that `process_event` was never called. + """ + self.cog_instance.process_event = AsyncMock() + self.cog_instance.resolve_message = AsyncMock(return_value=MockMessage()) + + with patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=False)): + await self.cog_instance.on_raw_reaction_add(self.payload) + + self.cog_instance.process_event.assert_not_called() + + async def test_on_raw_reaction_add_valid_event_is_processed(self): + """ + If the reaction event is valid, it is passed to `process_event`. + + This is the case when everything goes right: + * The reaction was placed in #incidents, and not by a bot + * The message was found successfully + * The message qualifies as an incident + + Additionally, we check that all arguments were passed as expected. + """ + incident = MockMessage(id=1) + + self.cog_instance.process_event = AsyncMock() + self.cog_instance.resolve_message = AsyncMock(return_value=incident) + + with patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=True)): + await self.cog_instance.on_raw_reaction_add(self.payload) + + self.cog_instance.process_event.assert_called_with( + "reaction", # Defined in `self.payload` + incident, + self.payload.member, + ) + + class TestOnMessage(TestIncidents): """ Tests for the `Incidents.on_message` listener. -- cgit v1.2.3 From e760bd38b5d625011318a9ddfc98bb52570d1c3a Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 19 Jun 2020 17:08:50 +0200 Subject: Incidents: review log levels; use `trace` where appropriate Logs useful when observing the internals but too verbose for DEBUG are reduced to TRACE. --- bot/cogs/moderation/incidents.py | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 16286bdab..da04c7d0d 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -64,10 +64,10 @@ async def add_signals(incident: discord.Message) -> None: # This will not raise, but it is a superfluous API call that can be avoided if signal_emoji.value in existing_reacts: - log.debug(f"Skipping emoji as it's already been placed: {signal_emoji}") + log.trace(f"Skipping emoji as it's already been placed: {signal_emoji}") else: - log.debug(f"Adding reaction: {signal_emoji}") + log.trace(f"Adding reaction: {signal_emoji}") await incident.add_reaction(signal_emoji.value) @@ -132,11 +132,11 @@ class Incidents(Cog): async for message in incidents.history(limit=limit): if not is_incident(message): - log.debug("Skipping message: not an incident") + log.trace("Skipping message: not an incident") continue if has_signals(message): - log.debug("Skipping message: already has all signals") + log.trace("Skipping message: already has all signals") continue await add_signals(message) @@ -179,7 +179,7 @@ class Incidents(Cog): return False else: - log.debug("Message archived successfully!") + log.trace("Message archived successfully!") return True def make_confirmation_task(self, incident: discord.Message, timeout: int = 5) -> asyncio.Task: @@ -189,7 +189,7 @@ class Incidents(Cog): If `timeout` passes, this will raise `asyncio.TimeoutError`, signaling that we haven't been able to confirm that the message was deleted. """ - log.debug(f"Confirmation task will wait {timeout=} seconds for {incident.id=} to be deleted") + log.trace(f"Confirmation task will wait {timeout=} seconds for {incident.id=} to be deleted") def check(payload: discord.RawReactionActionEvent) -> bool: return payload.message_id == incident.id @@ -225,7 +225,7 @@ class Incidents(Cog): # If we reach this point, we know that `emoji` is a `Signal` member signal = Signal(reaction) - log.debug(f"Received signal: {signal}") + log.trace(f"Received signal: {signal}") if signal not in (Signal.ACTIONED, Signal.NOT_ACTIONED): log.debug("Reaction was valid, but no action is currently defined for it") @@ -233,22 +233,22 @@ class Incidents(Cog): relay_successful = await self.archive(incident, signal) if not relay_successful: - log.debug("Original message will not be deleted as we failed to relay it to the archive") + log.trace("Original message will not be deleted as we failed to relay it to the archive") return timeout = 5 # Seconds confirmation_task = self.make_confirmation_task(incident, timeout) - log.debug("Deleting original message") + log.trace("Deleting original message") await incident.delete() - log.debug(f"Awaiting deletion confirmation: {timeout=} seconds") + log.trace(f"Awaiting deletion confirmation: {timeout=} seconds") try: await confirmation_task except asyncio.TimeoutError: log.warning(f"Did not receive incident deletion confirmation within {timeout} seconds!") else: - log.debug("Deletion was confirmed") + log.trace("Deletion was confirmed") async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]: """ @@ -264,22 +264,22 @@ class Incidents(Cog): This signals that the event for `message_id` should be ignored. """ await self.bot.wait_until_guild_available() # First make sure that the cache is ready - log.debug(f"Resolving message for: {message_id=}") + log.trace(f"Resolving message for: {message_id=}") message: discord.Message = self.bot._connection._get_message(message_id) # noqa: Private attribute if message is not None: - log.debug("Message was found in cache") + log.trace("Message was found in cache") return message - log.debug("Message not found, attempting to fetch") + log.trace("Message not found, attempting to fetch") try: message = await self.bot.get_channel(Channels.incidents).fetch_message(message_id) except discord.NotFound: - log.debug("Message doesn't exist, it was likely already relayed") + log.trace("Message doesn't exist, it was likely already relayed") except Exception as exc: log.exception("Failed to fetch message!", exc_info=exc) else: - log.debug("Message fetched successfully!") + log.trace("Message fetched successfully!") return message @Cog.listener() @@ -309,10 +309,10 @@ class Incidents(Cog): if payload.channel_id != Channels.incidents or payload.member.bot: return - log.debug(f"Received reaction add event in #incidents, waiting for crawler: {self.crawl_task.done()=}") + log.trace(f"Received reaction add event in #incidents, waiting for crawler: {self.crawl_task.done()=}") await self.crawl_task - log.debug(f"Acquiring event lock: {self.event_lock.locked()=}") + log.trace(f"Acquiring event lock: {self.event_lock.locked()=}") async with self.event_lock: message = await self.resolve_message(payload.message_id) @@ -325,7 +325,7 @@ class Incidents(Cog): return await self.process_event(str(payload.emoji), message, payload.member) - log.debug("Releasing event lock") + log.trace("Releasing event lock") @Cog.listener() async def on_message(self, message: discord.Message) -> None: -- cgit v1.2.3 From 8a0263f5a591be51e74c2b26369f74c6d8dfee09 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 19 Jun 2020 17:55:20 +0200 Subject: Incidents: remove broad noqa This was originally in place to silence a PyCharm warning regarding accessing the private attributes. However, since there is no corresponding error code to specify, the noqa will silence any linter warning, which is potentially dangerous, and seems to be bad practice. --- bot/cogs/moderation/incidents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index da04c7d0d..c733607e6 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -265,7 +265,7 @@ class Incidents(Cog): """ await self.bot.wait_until_guild_available() # First make sure that the cache is ready log.trace(f"Resolving message for: {message_id=}") - message: discord.Message = self.bot._connection._get_message(message_id) # noqa: Private attribute + message: discord.Message = self.bot._connection._get_message(message_id) if message is not None: log.trace("Message was found in cache") -- cgit v1.2.3 From 8a08ca3a29f6d6bda2ab71bc9fd70782be9869e4 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 19 Jun 2020 20:26:06 +0200 Subject: Incidents: annotate possible None type Caught during review by ks129. Co-authored-by: ks129 <45097959+ks129@users.noreply.github.com> --- bot/cogs/moderation/incidents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index c733607e6..c09d8e1a7 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -265,7 +265,7 @@ class Incidents(Cog): """ await self.bot.wait_until_guild_available() # First make sure that the cache is ready log.trace(f"Resolving message for: {message_id=}") - message: discord.Message = self.bot._connection._get_message(message_id) + message: t.Optional[discord.Message] = self.bot._connection._get_message(message_id) if message is not None: log.trace("Message was found in cache") -- cgit v1.2.3 From b563063c1a25a0a775ea1fb6cf31b7ef9725e14e Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 19 Jun 2020 20:33:17 +0200 Subject: Incidents: reduce excessive whitespace This is way too spacious for how little is happening here. Suggested by ks129. Co-authored-by: ks129 <45097959+ks129@users.noreply.github.com> --- bot/cogs/moderation/incidents.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index c09d8e1a7..70921462d 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -61,11 +61,8 @@ async def add_signals(incident: discord.Message) -> None: existing_reacts = own_reactions(incident) for signal_emoji in Signal: - - # This will not raise, but it is a superfluous API call that can be avoided - if signal_emoji.value in existing_reacts: + if signal_emoji.value in existing_reacts: # This would not raise, but it is a superfluous API call log.trace(f"Skipping emoji as it's already been placed: {signal_emoji}") - else: log.trace(f"Adding reaction: {signal_emoji}") await incident.add_reaction(signal_emoji.value) -- cgit v1.2.3 From 5e02d5ded0b9d1947e0e9d5455b134d9e2299a7d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 19 Jun 2020 21:48:18 -0700 Subject: Scheduler: use separate logger for each instance Each instance now requires a name to be specified, which will be used as the suffix of the logger's name. This removes the need to manually prepend every log message with the name. --- bot/utils/scheduling.py | 41 +++++++++++++++++------------------------ 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 8b778a093..002ef42cf 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -7,16 +7,14 @@ from functools import partial from bot.utils import CogABCMeta -log = logging.getLogger(__name__) - class Scheduler(metaclass=CogABCMeta): """Task scheduler.""" - def __init__(self): - # Keep track of the child cog's name so the logs are clear. - self.cog_name = self.__class__.__name__ + def __init__(self, name: str): + self.name = name + self._log = logging.getLogger(f"{__name__}.{name}") self._scheduled_tasks: t.Dict[t.Hashable, asyncio.Task] = {} @abstractmethod @@ -37,19 +35,17 @@ class Scheduler(metaclass=CogABCMeta): `task_data` is passed to the `Scheduler._scheduled_task()` coroutine. """ - log.trace(f"{self.cog_name}: scheduling task #{task_id}...") + self._log.trace(f"Scheduling task #{task_id}...") if task_id in self._scheduled_tasks: - log.debug( - f"{self.cog_name}: did not schedule task #{task_id}; task was already scheduled." - ) + self._log.debug(f"Did not schedule task #{task_id}; task was already scheduled.") return task = asyncio.create_task(self._scheduled_task(task_data)) task.add_done_callback(partial(self._task_done_callback, task_id)) self._scheduled_tasks[task_id] = task - log.debug(f"{self.cog_name}: scheduled task #{task_id} {id(task)}.") + self._log.debug(f"Scheduled task #{task_id} {id(task)}.") def cancel_task(self, task_id: t.Hashable, ignore_missing: bool = False) -> None: """ @@ -57,22 +53,22 @@ class Scheduler(metaclass=CogABCMeta): If `ignore_missing` is True, a warning will not be sent if a task isn't found. """ - log.trace(f"{self.cog_name}: cancelling task #{task_id}...") + self._log.trace(f"Cancelling task #{task_id}...") task = self._scheduled_tasks.get(task_id) if not task: if not ignore_missing: - log.warning(f"{self.cog_name}: failed to unschedule {task_id} (no task found).") + self._log.warning(f"Failed to unschedule {task_id} (no task found).") return del self._scheduled_tasks[task_id] task.cancel() - log.debug(f"{self.cog_name}: unscheduled task #{task_id} {id(task)}.") + self._log.debug(f"Unscheduled task #{task_id} {id(task)}.") def cancel_all(self) -> None: """Unschedule all known tasks.""" - log.debug(f"{self.cog_name}: unscheduling all tasks") + self._log.debug("Unscheduling all tasks") for task_id in self._scheduled_tasks.copy(): self.cancel_task(task_id, ignore_missing=True) @@ -84,24 +80,24 @@ class Scheduler(metaclass=CogABCMeta): If `done_task` and the task associated with `task_id` are different, then the latter will not be deleted. In this case, a new task was likely rescheduled with the same ID. """ - log.trace(f"{self.cog_name}: performing done callback for task #{task_id} {id(done_task)}.") + self._log.trace(f"Performing done callback for task #{task_id} {id(done_task)}.") scheduled_task = self._scheduled_tasks.get(task_id) if scheduled_task and done_task is scheduled_task: # A task for the ID exists and its the same as the done task. # Since this is the done callback, the task is already done so no need to cancel it. - log.trace(f"{self.cog_name}: deleting task #{task_id} {id(done_task)}.") + self._log.trace(f"Deleting task #{task_id} {id(done_task)}.") del self._scheduled_tasks[task_id] elif scheduled_task: # A new task was likely rescheduled with the same ID. - log.debug( - f"{self.cog_name}: the scheduled task #{task_id} {id(scheduled_task)} " + self._log.debug( + f"The scheduled task #{task_id} {id(scheduled_task)} " f"and the done task {id(done_task)} differ." ) elif not done_task.cancelled(): - log.warning( - f"{self.cog_name}: task #{task_id} not found while handling task {id(done_task)}! " + self._log.warning( + f"Task #{task_id} not found while handling task {id(done_task)}! " f"A task somehow got unscheduled improperly (i.e. deleted but not cancelled)." ) @@ -109,7 +105,4 @@ class Scheduler(metaclass=CogABCMeta): exception = done_task.exception() # Log the exception if one exists. if exception: - log.error( - f"{self.cog_name}: error in task #{task_id} {id(done_task)}!", - exc_info=exception - ) + self._log.error(f"Error in task #{task_id} {id(done_task)}!", exc_info=exception) -- cgit v1.2.3 From 5ded9651ab260c43053a660f2fc239aa722db5c7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 19 Jun 2020 21:59:05 -0700 Subject: Scheduler: directly take the awaitable to schedule This is a major change which simplifies the interface. It removes the need to implement an abstract method, which means the class can now be instantiated rather than subclassed. --- bot/utils/scheduling.py | 27 ++++----------------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 002ef42cf..70fb1972b 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -2,13 +2,10 @@ import asyncio import contextlib import logging import typing as t -from abc import abstractmethod from functools import partial -from bot.utils import CogABCMeta - -class Scheduler(metaclass=CogABCMeta): +class Scheduler: """Task scheduler.""" def __init__(self, name: str): @@ -17,31 +14,15 @@ class Scheduler(metaclass=CogABCMeta): self._log = logging.getLogger(f"{__name__}.{name}") self._scheduled_tasks: t.Dict[t.Hashable, asyncio.Task] = {} - @abstractmethod - async def _scheduled_task(self, task_object: t.Any) -> None: - """ - A coroutine which handles the scheduling. - - This is added to the scheduled tasks, and should wait the task duration, execute the desired - code, then clean up the task. - - For example, in Reminders this will wait for the reminder duration, send the reminder, - then make a site API request to delete the reminder from the database. - """ - - def schedule_task(self, task_id: t.Hashable, task_data: t.Any) -> None: - """ - Schedules a task. - - `task_data` is passed to the `Scheduler._scheduled_task()` coroutine. - """ + def schedule_task(self, task_id: t.Hashable, task: t.Awaitable) -> None: + """Schedule the execution of a task.""" self._log.trace(f"Scheduling task #{task_id}...") if task_id in self._scheduled_tasks: self._log.debug(f"Did not schedule task #{task_id}; task was already scheduled.") return - task = asyncio.create_task(self._scheduled_task(task_data)) + task = asyncio.create_task(task) task.add_done_callback(partial(self._task_done_callback, task_id)) self._scheduled_tasks[task_id] = task -- cgit v1.2.3 From 4bb6bde1c79f3ffd3d452dd7ffe489d9b093f567 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 19 Jun 2020 22:00:26 -0700 Subject: Scheduler: name tasks Makes them easier to identify when debugging. --- bot/utils/scheduling.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 70fb1972b..f2640ed5e 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -22,7 +22,7 @@ class Scheduler: self._log.debug(f"Did not schedule task #{task_id}; task was already scheduled.") return - task = asyncio.create_task(task) + task = asyncio.create_task(task, name=f"{self.name}_{task_id}") task.add_done_callback(partial(self._task_done_callback, task_id)) self._scheduled_tasks[task_id] = task -- cgit v1.2.3 From 5130611719735d8e58c1d0faeeeaffe4553843dd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 19 Jun 2020 22:49:41 -0700 Subject: Scheduler: add support for in operator --- bot/utils/scheduling.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index f2640ed5e..00fca4169 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -14,6 +14,10 @@ class Scheduler: self._log = logging.getLogger(f"{__name__}.{name}") self._scheduled_tasks: t.Dict[t.Hashable, asyncio.Task] = {} + def __contains__(self, task_id: t.Hashable) -> bool: + """Return True if a task with the given `task_id` is currently scheduled.""" + return task_id in self._scheduled_tasks + def schedule_task(self, task_id: t.Hashable, task: t.Awaitable) -> None: """Schedule the execution of a task.""" self._log.trace(f"Scheduling task #{task_id}...") -- cgit v1.2.3 From c81d3bdd1769a02ba02af18e52150629e655e3c9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 19 Jun 2020 23:02:24 -0700 Subject: Scheduler: use pop instead of get when cancelling --- bot/utils/scheduling.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 00fca4169..6f498348d 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -39,17 +39,17 @@ class Scheduler: If `ignore_missing` is True, a warning will not be sent if a task isn't found. """ self._log.trace(f"Cancelling task #{task_id}...") - task = self._scheduled_tasks.get(task_id) - if not task: + try: + task = self._scheduled_tasks.pop(task_id) + except KeyError: if not ignore_missing: self._log.warning(f"Failed to unschedule {task_id} (no task found).") - return - - del self._scheduled_tasks[task_id] - task.cancel() + else: + del self._scheduled_tasks[task_id] + task.cancel() - self._log.debug(f"Unscheduled task #{task_id} {id(task)}.") + self._log.debug(f"Unscheduled task #{task_id} {id(task)}.") def cancel_all(self) -> None: """Unschedule all known tasks.""" -- cgit v1.2.3 From 662ca588ac352fc346fae973dead5052c7b4af59 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 20 Jun 2020 12:22:11 +0200 Subject: Incidents: remove redundant `exc_info` passing Pointed out by Mark during review that this is unnecessary, as logging using `exception` automatically appends the `exc_info` of the handled exception when done in an except block. Co-authored-by: MarkKoz --- bot/cogs/moderation/incidents.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 70921462d..5f4291953 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -171,8 +171,8 @@ class Incidents(Cog): # Finally add the `outcome` emoji await message.add_reaction(outcome.value) - except Exception as exc: - log.exception("Failed to archive incident to #incidents-archive", exc_info=exc) + except Exception: + log.exception("Failed to archive incident to #incidents-archive") return False else: @@ -273,8 +273,8 @@ class Incidents(Cog): message = await self.bot.get_channel(Channels.incidents).fetch_message(message_id) except discord.NotFound: log.trace("Message doesn't exist, it was likely already relayed") - except Exception as exc: - log.exception("Failed to fetch message!", exc_info=exc) + except Exception: + log.exception("Failed to fetch message!") else: log.trace("Message fetched successfully!") return message -- cgit v1.2.3 From 20b27f32c68673b603a6e6e41859f7672b6e0133 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 20 Jun 2020 12:28:18 +0200 Subject: Incidents: make logs contain the message id they pertain to Suggested by Mark during review. Co-authored-by: MarkKoz --- bot/cogs/moderation/incidents.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 5f4291953..33c3bee51 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -129,11 +129,11 @@ class Incidents(Cog): async for message in incidents.history(limit=limit): if not is_incident(message): - log.trace("Skipping message: not an incident") + log.trace(f"Skipping message {message.id}: not an incident") continue if has_signals(message): - log.trace("Skipping message: already has all signals") + log.trace(f"Skipping message {message.id}: already has all signals") continue await add_signals(message) @@ -172,7 +172,7 @@ class Incidents(Cog): await message.add_reaction(outcome.value) except Exception: - log.exception("Failed to archive incident to #incidents-archive") + log.exception(f"Failed to archive incident {incident.id} to #incidents-archive") return False else: @@ -274,7 +274,7 @@ class Incidents(Cog): except discord.NotFound: log.trace("Message doesn't exist, it was likely already relayed") except Exception: - log.exception("Failed to fetch message!") + log.exception(f"Failed to fetch message {message_id}!") else: log.trace("Message fetched successfully!") return message -- cgit v1.2.3 From 6d3a91cf5d51e6e2a2f10c08718a7c8de0d521ed Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 20 Jun 2020 12:35:46 +0200 Subject: Incidents: make crawl limit & sleep module-level constants Requested during review. Co-authored-by: ks129 <45097959+ks129@users.noreply.github.com> Co-authored-by: Joseph Banks --- bot/cogs/moderation/incidents.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 33c3bee51..4e6743224 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -11,6 +11,14 @@ from bot.constants import Channels, Emojis, Roles, Webhooks log = logging.getLogger(__name__) +# Amount of messages for `crawl_task` to process at most on start-up - limited to 50 +# as in practice, there should never be this many messages, and if there are, +# something has likely gone very wrong +CRAWL_LIMIT = 50 + +# Seconds for `crawl_task` to sleep after adding reactions to a message +CRAWL_SLEEP = 2 + class Signal(Enum): """ @@ -114,19 +122,14 @@ class Incidents(Cog): Once this task is scheduled, listeners that change messages should await it. The crawl assumes that the channel history doesn't change as we go over it. + + Behaviour is configured by: `CRAWL_LIMIT`, `CRAWL_SLEEP`. """ await self.bot.wait_until_guild_available() incidents: discord.TextChannel = self.bot.get_channel(Channels.incidents) - # Limit the query at 50 as in practice, there should never be this many messages, - # and if there are, something has likely gone very wrong - limit = 50 - - # Seconds to sleep after adding reactions to a message - sleep = 2 - - log.debug(f"Crawling messages in #incidents: {limit=}, {sleep=}") - async for message in incidents.history(limit=limit): + log.debug(f"Crawling messages in #incidents: {CRAWL_LIMIT=}, {CRAWL_SLEEP=}") + async for message in incidents.history(limit=CRAWL_LIMIT): if not is_incident(message): log.trace(f"Skipping message {message.id}: not an incident") @@ -137,7 +140,7 @@ class Incidents(Cog): continue await add_signals(message) - await asyncio.sleep(sleep) + await asyncio.sleep(CRAWL_SLEEP) log.debug("Crawl task finished!") -- cgit v1.2.3 From b8ada89bd45e6b8efd17fba79e70ce91a59b24fc Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 20 Jun 2020 12:42:53 +0200 Subject: Incidents: simplify set operation in `has_signals` Using `issubset` is a much simpler & more readable way of expressing the relationship between the two sets. Suggested by Mark during review. Co-authored-by: MarkKoz --- bot/cogs/moderation/incidents.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 4e6743224..089a5bc9f 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -56,8 +56,7 @@ def own_reactions(message: discord.Message) -> t.Set[str]: def has_signals(message: discord.Message) -> bool: """True if `message` already has all `Signal` reactions, False otherwise.""" - missing_signals = ALLOWED_EMOJI - own_reactions(message) # In `ALLOWED_EMOJI` but not in `own_reactions(message)` - return not missing_signals + return ALLOWED_EMOJI.issubset(own_reactions(message)) async def add_signals(incident: discord.Message) -> None: -- cgit v1.2.3 From 98b8947ab7865e33f18da8e2a62b26405676e8e4 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 20 Jun 2020 13:13:45 +0200 Subject: Incidents: try-except Signal creation Suggested by Mark during review. This follows the "ask for forgiveness rather than permission" paradigm, ends up being less code to read, and may be seen as more logical / safer. The `ALLOWED_EMOJI` set was renamed to `ALL_SIGNALS` as this now better communicates the set's purpose. Tests adjusted as appropriate. Co-authored-by: MarkKoz --- bot/cogs/moderation/incidents.py | 18 +++++++++++------- tests/bot/cogs/moderation/test_incidents.py | 8 ++++---- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 089a5bc9f..41a98bcb7 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -33,9 +33,11 @@ class Signal(Enum): INVESTIGATING = Emojis.incident_investigating -# Reactions from roles not listed here, or using emoji not listed here, will be removed +# Reactions from roles not listed here will be removed ALLOWED_ROLES: t.Set[int] = {Roles.moderators, Roles.admins, Roles.owners} -ALLOWED_EMOJI: t.Set[str] = {signal.value for signal in Signal} + +# Message must have all of these emoji to pass the `has_signals` check +ALL_SIGNALS: t.Set[str] = {signal.value for signal in Signal} def is_incident(message: discord.Message) -> bool: @@ -56,7 +58,7 @@ def own_reactions(message: discord.Message) -> t.Set[str]: def has_signals(message: discord.Message) -> bool: """True if `message` already has all `Signal` reactions, False otherwise.""" - return ALLOWED_EMOJI.issubset(own_reactions(message)) + return ALL_SIGNALS.issubset(own_reactions(message)) async def add_signals(incident: discord.Message) -> None: @@ -96,7 +98,9 @@ class Incidents(Cog): * See: `on_message` On reaction: - * Remove reaction if not permitted (`ALLOWED_EMOJI`, `ALLOWED_ROLES`) + * Remove reaction if not permitted + * User does not have any of the roles in `ALLOWED_ROLES` + * Used emoji is not a `Signal` member * If `Signal.ACTIONED` or `Signal.NOT_ACTIONED` were chosen, attempt to relay the incident message to #incidents-archive * If relay successful, delete original message @@ -217,13 +221,13 @@ class Incidents(Cog): await incident.remove_reaction(reaction, member) return - if reaction not in ALLOWED_EMOJI: + try: + signal = Signal(reaction) + except ValueError: log.debug(f"Removing invalid reaction: emoji {reaction} is not a valid signal") await incident.remove_reaction(reaction, member) return - # If we reach this point, we know that `emoji` is a `Signal` member - signal = Signal(reaction) log.trace(f"Received signal: {signal}") if signal not in (Signal.ACTIONED, Signal.NOT_ACTIONED): diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 55b15ec9e..862736785 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -131,17 +131,17 @@ class TestOwnReactions(unittest.TestCase): self.assertSetEqual(incidents.own_reactions(message), {"A", "B"}) -@patch("bot.cogs.moderation.incidents.ALLOWED_EMOJI", {"A", "B"}) +@patch("bot.cogs.moderation.incidents.ALL_SIGNALS", {"A", "B"}) class TestHasSignals(unittest.TestCase): """ Assertions for the `has_signals` function. - We patch `ALLOWED_EMOJI` globally. Each test function then patches `own_reactions` + We patch `ALL_SIGNALS` globally. Each test function then patches `own_reactions` as appropriate. """ def test_has_signals_true(self): - """True when `own_reactions` returns all emoji in `ALLOWED_EMOJI`.""" + """True when `own_reactions` returns all emoji in `ALL_SIGNALS`.""" message = MockMessage() own_reactions = MagicMock(return_value={"A", "B"}) @@ -149,7 +149,7 @@ class TestHasSignals(unittest.TestCase): self.assertTrue(incidents.has_signals(message)) def test_has_signals_false(self): - """False when `own_reactions` does not return all emoji in `ALLOWED_EMOJI`.""" + """False when `own_reactions` does not return all emoji in `ALL_SIGNALS`.""" message = MockMessage() own_reactions = MagicMock(return_value={"A", "C"}) -- cgit v1.2.3 From 20dbd177f227511b9c3cb678ab45a67558cd3d7f Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 20 Jun 2020 13:15:43 +0200 Subject: Incidents tests: remove unnecessary patch This is already being patched at class-level. --- tests/bot/cogs/moderation/test_incidents.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 862736785..9f0553216 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -360,7 +360,6 @@ class TestMakeConfirmationTask(TestIncidents): class TestProcessEvent(TestIncidents): """Tests for the `Incidents.process_event` coroutine.""" - @patch("bot.cogs.moderation.incidents.ALLOWED_ROLES", {1, 2}) async def test_process_event_bad_role(self): """The reaction is removed when the author lacks all allowed roles.""" incident = MockMessage() -- cgit v1.2.3 From a8b4e394d9da57287cd9497cd9bb0a97fa467e84 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 20 Jun 2020 14:02:48 +0200 Subject: Incidents: de-clyde archive webhook username With PR #1009 merged, we now apply the same fix to our relay function. This prevents the "clyde" word from sneaking into the webhook username, which is forbidden and will return a 400. --- bot/cogs/moderation/incidents.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 41a98bcb7..040f2c0c8 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -8,6 +8,7 @@ from discord.ext.commands import Cog from bot.bot import Bot from bot.constants import Channels, Emojis, Roles, Webhooks +from bot.utils.messages import sub_clyde log = logging.getLogger(__name__) @@ -169,7 +170,7 @@ class Incidents(Cog): # Now relay the incident message: discord.Message = await webhook.send( content=incident.clean_content, # Clean content will prevent mentions from pinging - username=incident.author.name, + username=sub_clyde(incident.author.name), avatar_url=incident.author.avatar_url, wait=True, # This makes the method return the sent Message object ) -- cgit v1.2.3 From f240a970c6b97d201959d25a79a8babafed1c2b1 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 20 Jun 2020 14:15:34 +0200 Subject: Incidents tests: assert webhook username is de-clyded See: a8b4e394d9da57287cd9497cd9bb0a97fa467e84 --- tests/bot/cogs/moderation/test_incidents.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 9f0553216..2fc9180cf 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -319,6 +319,25 @@ class TestArchive(TestIncidents): # Finally check that the method returned True self.assertTrue(archive_return) + async def test_archive_clyde_username(self): + """ + The archive webhook username is cleansed using `sub_clyde`. + + Discord will reject any webhook with "clyde" in the username field, as it impersonates + the official Clyde bot. Since we do not control what the username will be (the incident + author name is used), we must ensure the name is cleansed, otherwise the relay may fail. + + This test assumes the username is passed as a kwarg. If this test fails, please review + whether the passed argument is being retrieved correctly. + """ + webhook = MockAsyncWebhook() + self.cog_instance.bot.fetch_webhook = AsyncMock(return_value=webhook) + + message_from_clyde = MockMessage(author=MockUser(name="clyde the great")) + await self.cog_instance.archive(message_from_clyde, MagicMock(incidents.Signal)) + + self.assertNotIn("clyde", webhook.send.call_args.kwargs["username"]) + class TestMakeConfirmationTask(TestIncidents): """ -- cgit v1.2.3 From e09276191f5bcaa0dbf34fdbff51654027528688 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 20 Jun 2020 09:37:21 -0700 Subject: Scheduler: remove ignore_missing param The ability to use the `in` operator makes this obsolete. Callers can check themselves if a task exists before they try to cancel it. --- bot/utils/scheduling.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 6f498348d..d9b48034b 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -32,19 +32,14 @@ class Scheduler: self._scheduled_tasks[task_id] = task self._log.debug(f"Scheduled task #{task_id} {id(task)}.") - def cancel_task(self, task_id: t.Hashable, ignore_missing: bool = False) -> None: - """ - Unschedule the task identified by `task_id`. - - If `ignore_missing` is True, a warning will not be sent if a task isn't found. - """ + def cancel_task(self, task_id: t.Hashable) -> None: + """Unschedule the task identified by `task_id`. Log a warning if the task doesn't exist.""" self._log.trace(f"Cancelling task #{task_id}...") try: task = self._scheduled_tasks.pop(task_id) except KeyError: - if not ignore_missing: - self._log.warning(f"Failed to unschedule {task_id} (no task found).") + self._log.warning(f"Failed to unschedule {task_id} (no task found).") else: del self._scheduled_tasks[task_id] task.cancel() @@ -56,7 +51,7 @@ class Scheduler: self._log.debug("Unscheduling all tasks") for task_id in self._scheduled_tasks.copy(): - self.cancel_task(task_id, ignore_missing=True) + self.cancel_task(task_id) def _task_done_callback(self, task_id: t.Hashable, done_task: asyncio.Task) -> None: """ @@ -70,7 +65,7 @@ class Scheduler: scheduled_task = self._scheduled_tasks.get(task_id) if scheduled_task and done_task is scheduled_task: - # A task for the ID exists and its the same as the done task. + # A task for the ID exists and is the same as the done task. # Since this is the done callback, the task is already done so no need to cancel it. self._log.trace(f"Deleting task #{task_id} {id(done_task)}.") del self._scheduled_tasks[task_id] -- cgit v1.2.3 From 19e41aae30e19374054d9ed37f36faa2104f751c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 20 Jun 2020 13:20:24 -0700 Subject: Scheduler: drop _task suffix from method names It's redundant. After all, this scheduler cannot schedule anything else. --- bot/utils/scheduling.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index d9b48034b..4a003d4fe 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -18,7 +18,7 @@ class Scheduler: """Return True if a task with the given `task_id` is currently scheduled.""" return task_id in self._scheduled_tasks - def schedule_task(self, task_id: t.Hashable, task: t.Awaitable) -> None: + def schedule(self, task_id: t.Hashable, task: t.Awaitable) -> None: """Schedule the execution of a task.""" self._log.trace(f"Scheduling task #{task_id}...") @@ -32,7 +32,7 @@ class Scheduler: self._scheduled_tasks[task_id] = task self._log.debug(f"Scheduled task #{task_id} {id(task)}.") - def cancel_task(self, task_id: t.Hashable) -> None: + def cancel(self, task_id: t.Hashable) -> None: """Unschedule the task identified by `task_id`. Log a warning if the task doesn't exist.""" self._log.trace(f"Cancelling task #{task_id}...") @@ -51,7 +51,7 @@ class Scheduler: self._log.debug("Unscheduling all tasks") for task_id in self._scheduled_tasks.copy(): - self.cancel_task(task_id) + self.cancel(task_id) def _task_done_callback(self, task_id: t.Hashable, done_task: asyncio.Task) -> None: """ -- cgit v1.2.3 From ee47b2afda1f8f409c1c60bd874d15b1d1a52ca6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 20 Jun 2020 13:23:50 -0700 Subject: Scheduler: rename "task" param to "coroutine" Naming it "task" is inaccurate because `create_task` accepts a coroutine rather than a Task. What it does is wrap the coroutine in a Task. --- bot/utils/scheduling.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 4a003d4fe..625b726d2 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -18,15 +18,15 @@ class Scheduler: """Return True if a task with the given `task_id` is currently scheduled.""" return task_id in self._scheduled_tasks - def schedule(self, task_id: t.Hashable, task: t.Awaitable) -> None: - """Schedule the execution of a task.""" + def schedule(self, task_id: t.Hashable, coroutine: t.Coroutine) -> None: + """Schedule the execution of a coroutine.""" self._log.trace(f"Scheduling task #{task_id}...") if task_id in self._scheduled_tasks: self._log.debug(f"Did not schedule task #{task_id}; task was already scheduled.") return - task = asyncio.create_task(task, name=f"{self.name}_{task_id}") + task = asyncio.create_task(coroutine, name=f"{self.name}_{task_id}") task.add_done_callback(partial(self._task_done_callback, task_id)) self._scheduled_tasks[task_id] = task -- cgit v1.2.3 From f807bf72fa649242b910e309d7043c8bdc2b1fdc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 20 Jun 2020 13:54:20 -0700 Subject: Scheduler: add a method to schedule with a delay --- bot/utils/scheduling.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 625b726d2..ac67278f6 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -32,6 +32,10 @@ class Scheduler: self._scheduled_tasks[task_id] = task self._log.debug(f"Scheduled task #{task_id} {id(task)}.") + def schedule_later(self, delay: t.Union[int, float], task_id: t.Hashable, coroutine: t.Coroutine) -> None: + """Schedule `coroutine` to be executed after the given `delay` number of seconds.""" + self.schedule(task_id, self._await_later(delay, coroutine)) + def cancel(self, task_id: t.Hashable) -> None: """Unschedule the task identified by `task_id`. Log a warning if the task doesn't exist.""" self._log.trace(f"Cancelling task #{task_id}...") @@ -53,6 +57,21 @@ class Scheduler: for task_id in self._scheduled_tasks.copy(): self.cancel(task_id) + async def _await_later(self, delay: t.Union[int, float], coroutine: t.Coroutine) -> None: + """Await `coroutine` after the given `delay` number of seconds.""" + try: + self._log.trace(f"Waiting {delay} seconds before awaiting the coroutine.") + await asyncio.sleep(delay) + + # Use asyncio.shield to prevent the coroutine from cancelling itself. + self._log.trace("Done waiting; now awaiting the coroutine.") + await asyncio.shield(coroutine) + finally: + # Close it to prevent unawaited coroutine warnings, + # which would happen if the task was cancelled during the sleep. + self._log.trace("Explicitly closing the coroutine.") + coroutine.close() + def _task_done_callback(self, task_id: t.Hashable, done_task: asyncio.Task) -> None: """ Delete the task and raise its exception if one exists. -- cgit v1.2.3 From dfcf71f36c85e357028ea2f86aac7e38c6b8ab47 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 20 Jun 2020 14:02:23 -0700 Subject: Scheduler: add a method to schedule at a specific datetime --- bot/utils/scheduling.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index ac67278f6..f5308059a 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -2,6 +2,7 @@ import asyncio import contextlib import logging import typing as t +from datetime import datetime from functools import partial @@ -32,6 +33,18 @@ class Scheduler: self._scheduled_tasks[task_id] = task self._log.debug(f"Scheduled task #{task_id} {id(task)}.") + def schedule_at(self, time: datetime, task_id: t.Hashable, coroutine: t.Coroutine) -> None: + """ + Schedule `coroutine` to be executed at the given naïve UTC `time`. + + If `time` is in the past, schedule `coroutine` immediately. + """ + delay = (time - datetime.utcnow()).total_seconds() + if delay > 0: + coroutine = self._await_later(delay, coroutine) + + self.schedule(task_id, coroutine) + def schedule_later(self, delay: t.Union[int, float], task_id: t.Hashable, coroutine: t.Coroutine) -> None: """Schedule `coroutine` to be executed after the given `delay` number of seconds.""" self.schedule(task_id, self._await_later(delay, coroutine)) -- cgit v1.2.3 From f2f4b425dc8988ffaf9b1ebe8c2a5b449a50a48e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 20 Jun 2020 16:25:48 -0700 Subject: Update Filtering's scheduler to the new API --- bot/cogs/filtering.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 76ea68660..099606b82 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -19,7 +19,6 @@ from bot.constants import ( ) from bot.utils.redis_cache import RedisCache from bot.utils.scheduling import Scheduler -from bot.utils.time import wait_until log = logging.getLogger(__name__) @@ -60,7 +59,7 @@ def expand_spoilers(text: str) -> str: OFFENSIVE_MSG_DELETE_TIME = timedelta(days=Filter.offensive_msg_delete_days) -class Filtering(Cog, Scheduler): +class Filtering(Cog): """Filtering out invites, blacklisting domains, and warning us of certain regular expressions.""" # Redis cache mapping a user ID to the last timestamp a bad nickname alert was sent @@ -68,8 +67,7 @@ class Filtering(Cog, Scheduler): def __init__(self, bot: Bot): self.bot = bot - super().__init__() - + self.scheduler = Scheduler(self.__class__.__name__) self.name_lock = asyncio.Lock() staff_mistake_str = "If you believe this was a mistake, please let staff know!" @@ -268,7 +266,7 @@ class Filtering(Cog, Scheduler): } await self.bot.api_client.post('bot/offensive-messages', json=data) - self.schedule_task(msg.id, data) + self.schedule_msg_delete(data) log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}") if is_private: @@ -457,12 +455,10 @@ class Filtering(Cog, Scheduler): except discord.errors.Forbidden: await channel.send(f"{filtered_member.mention} {reason}") - async def _scheduled_task(self, msg: dict) -> None: + def schedule_msg_delete(self, msg: dict) -> None: """Delete an offensive message once its deletion date is reached.""" delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None) - - await wait_until(delete_at) - await self.delete_offensive_msg(msg) + self.scheduler.schedule_at(delete_at, msg['id'], self.delete_offensive_msg(msg)) async def reschedule_offensive_msg_deletion(self) -> None: """Get all the pending message deletion from the API and reschedule them.""" @@ -477,7 +473,7 @@ class Filtering(Cog, Scheduler): if delete_at < now: await self.delete_offensive_msg(msg) else: - self.schedule_task(msg['id'], msg) + self.schedule_msg_delete(msg) async def delete_offensive_msg(self, msg: Mapping[str, str]) -> None: """Delete an offensive message, and then delete it from the db.""" -- cgit v1.2.3 From 90f0cb34cefdc362336cfb27b2e94f8925f312f4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 20 Jun 2020 16:42:26 -0700 Subject: Update Reminders's scheduler to the new API --- bot/cogs/reminders.py | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index c242d2920..0d20bdb2b 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -17,7 +17,7 @@ from bot.converters import Duration from bot.pagination import LinePaginator from bot.utils.checks import without_role_check from bot.utils.scheduling import Scheduler -from bot.utils.time import humanize_delta, wait_until +from bot.utils.time import humanize_delta log = logging.getLogger(__name__) @@ -25,12 +25,12 @@ WHITELISTED_CHANNELS = Guild.reminder_whitelist MAXIMUM_REMINDERS = 5 -class Reminders(Scheduler, Cog): +class Reminders(Cog): """Provide in-channel reminder functionality.""" def __init__(self, bot: Bot): self.bot = bot - super().__init__() + self.scheduler = Scheduler(self.__class__.__name__) self.bot.loop.create_task(self.reschedule_reminders()) @@ -56,7 +56,7 @@ class Reminders(Scheduler, Cog): late = relativedelta(now, remind_at) await self.send_reminder(reminder, late) else: - self.schedule_task(reminder["id"], reminder) + self.schedule_reminder(reminder) def ensure_valid_reminder( self, @@ -99,17 +99,18 @@ class Reminders(Scheduler, Cog): await ctx.send(embed=embed) - async def _scheduled_task(self, reminder: dict) -> None: + def schedule_reminder(self, reminder: dict) -> None: """A coroutine which sends the reminder once the time is reached, and cancels the running task.""" reminder_id = reminder["id"] reminder_datetime = isoparse(reminder['expiration']).replace(tzinfo=None) - # Send the reminder message once the desired duration has passed - await wait_until(reminder_datetime) - await self.send_reminder(reminder) + async def _remind() -> None: + await self.send_reminder(reminder) - log.debug(f"Deleting reminder {reminder_id} (the user has been reminded).") - await self._delete_reminder(reminder_id) + log.debug(f"Deleting reminder {reminder_id} (the user has been reminded).") + await self._delete_reminder(reminder_id) + + self.scheduler.schedule_at(reminder_datetime, reminder_id, _remind()) async def _delete_reminder(self, reminder_id: str, cancel_task: bool = True) -> None: """Delete a reminder from the database, given its ID, and cancel the running task.""" @@ -117,15 +118,15 @@ class Reminders(Scheduler, Cog): if cancel_task: # Now we can remove it from the schedule list - self.cancel_task(reminder_id) + self.scheduler.cancel(reminder_id) async def _reschedule_reminder(self, reminder: dict) -> None: """Reschedule a reminder object.""" log.trace(f"Cancelling old task #{reminder['id']}") - self.cancel_task(reminder["id"]) + self.scheduler.cancel(reminder["id"]) log.trace(f"Scheduling new task #{reminder['id']}") - self.schedule_task(reminder["id"], reminder) + self.schedule_reminder(reminder) async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: """Send the reminder.""" @@ -223,7 +224,7 @@ class Reminders(Scheduler, Cog): delivery_dt=expiration, ) - self.schedule_task(reminder["id"], reminder) + self.schedule_reminder(reminder) @remind_group.command(name="list") async def list_reminders(self, ctx: Context) -> t.Optional[discord.Message]: -- cgit v1.2.3 From 6c76a04dab61de0ae4ea786c97f160805640d0c5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 20 Jun 2020 16:45:10 -0700 Subject: Update Silence's scheduler to the new API --- bot/cogs/moderation/silence.py | 32 ++++++-------------------------- 1 file changed, 6 insertions(+), 26 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index c8ab6443b..ae4fb7b64 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -1,7 +1,7 @@ import asyncio import logging from contextlib import suppress -from typing import NamedTuple, Optional +from typing import Optional from discord import TextChannel from discord.ext import commands, tasks @@ -16,13 +16,6 @@ from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) -class TaskData(NamedTuple): - """Data for a scheduled task.""" - - delay: int - ctx: Context - - class SilenceNotifier(tasks.Loop): """Loop notifier for posting notices to `alert_channel` containing added channels.""" @@ -61,25 +54,17 @@ class SilenceNotifier(tasks.Loop): await self._alert_channel.send(f"<@&{Roles.moderators}> currently silenced channels: {channels_text}") -class Silence(Scheduler, commands.Cog): +class Silence(commands.Cog): """Commands for stopping channel messages for `verified` role in a channel.""" def __init__(self, bot: Bot): - super().__init__() self.bot = bot + self.scheduler = Scheduler(self.__class__.__name__) self.muted_channels = set() + self._get_instance_vars_task = self.bot.loop.create_task(self._get_instance_vars()) self._get_instance_vars_event = asyncio.Event() - async def _scheduled_task(self, task: TaskData) -> None: - """Calls `self.unsilence` on expired silenced channel to unsilence it.""" - await asyncio.sleep(task.delay) - log.info("Unsilencing channel after set delay.") - - # Because `self.unsilence` explicitly cancels this scheduled task, it is shielded - # to avoid prematurely cancelling itself - await asyncio.shield(task.ctx.invoke(self.unsilence)) - async def _get_instance_vars(self) -> None: """Get instance variables after they're available to get from the guild.""" await self.bot.wait_until_guild_available() @@ -109,12 +94,7 @@ class Silence(Scheduler, commands.Cog): await ctx.send(f"{Emojis.check_mark} silenced current channel for {duration} minute(s).") - task_data = TaskData( - delay=duration*60, - ctx=ctx - ) - - self.schedule_task(ctx.channel.id, task_data) + self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence)) @commands.command(aliases=("unhush",)) async def unsilence(self, ctx: Context) -> None: @@ -164,7 +144,7 @@ class Silence(Scheduler, commands.Cog): if current_overwrite.send_messages is False: await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=None)) log.info(f"Unsilenced channel #{channel} ({channel.id}).") - self.cancel_task(channel.id) + self.scheduler.cancel(channel.id) self.notifier.remove_channel(channel) self.muted_channels.discard(channel) return True -- cgit v1.2.3 From 0e69211295c6d7656b776870aa2bd8aab9244f5f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 20 Jun 2020 16:56:14 -0700 Subject: Update HelpChannels's scheduler to the new API --- bot/cogs/help_channels.py | 70 ++++++++++++++--------------------------------- 1 file changed, 20 insertions(+), 50 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 187adfe51..93ef07c84 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -1,5 +1,4 @@ import asyncio -import inspect import json import logging import random @@ -57,14 +56,7 @@ through our guide for [asking a good question]({ASKING_GUIDE_URL}). CoroutineFunc = t.Callable[..., t.Coroutine] -class TaskData(t.NamedTuple): - """Data for a scheduled task.""" - - wait_time: int - callback: t.Awaitable - - -class HelpChannels(Scheduler, commands.Cog): +class HelpChannels(commands.Cog): """ Manage the help channel system of the guild. @@ -114,9 +106,8 @@ class HelpChannels(Scheduler, commands.Cog): claim_times = RedisCache() def __init__(self, bot: Bot): - super().__init__() - self.bot = bot + self.scheduler = Scheduler(self.__class__.__name__) # Categories self.available_category: discord.CategoryChannel = None @@ -145,7 +136,7 @@ class HelpChannels(Scheduler, commands.Cog): for task in self.queue_tasks: task.cancel() - self.cancel_all() + self.scheduler.cancel_all() def create_channel_queue(self) -> asyncio.Queue: """ @@ -229,10 +220,11 @@ class HelpChannels(Scheduler, commands.Cog): await self.remove_cooldown_role(ctx.author) # Ignore missing task when cooldown has passed but the channel still isn't dormant. - self.cancel_task(ctx.author.id, ignore_missing=True) + if ctx.author.id in self.scheduler: + self.scheduler.cancel(ctx.author.id) await self.move_to_dormant(ctx.channel, "command") - self.cancel_task(ctx.channel.id) + self.scheduler.cancel(ctx.channel.id) else: log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") @@ -474,16 +466,15 @@ class HelpChannels(Scheduler, commands.Cog): else: # Cancel the existing task, if any. if has_task: - self.cancel_task(channel.id) - - data = TaskData(idle_seconds - time_elapsed, self.move_idle_channel(channel)) + self.scheduler.cancel(channel.id) + delay = idle_seconds - time_elapsed log.info( f"#{channel} ({channel.id}) is still active; " - f"scheduling it to be moved after {data.wait_time} seconds." + f"scheduling it to be moved after {delay} seconds." ) - self.schedule_task(channel.id, data) + self.scheduler.schedule_later(delay, channel.id, self.move_idle_channel(channel)) async def move_to_bottom_position(self, channel: discord.TextChannel, category_id: int, **options) -> None: """ @@ -588,8 +579,7 @@ class HelpChannels(Scheduler, commands.Cog): timeout = constants.HelpChannels.idle_minutes * 60 log.trace(f"Scheduling #{channel} ({channel.id}) to become dormant in {timeout} sec.") - data = TaskData(timeout, self.move_idle_channel(channel)) - self.schedule_task(channel.id, data) + self.scheduler.schedule_later(timeout, channel.id, self.move_idle_channel(channel)) self.report_stats() async def notify(self) -> None: @@ -722,10 +712,10 @@ class HelpChannels(Scheduler, commands.Cog): log.info(f"Claimant of #{msg.channel} ({msg.author}) deleted message, channel is empty now. Rescheduling task.") # Cancel existing dormant task before scheduling new. - self.cancel_task(msg.channel.id) + self.scheduler.cancel(msg.channel.id) - task = TaskData(constants.HelpChannels.deleted_idle_minutes * 60, self.move_idle_channel(msg.channel)) - self.schedule_task(msg.channel.id, task) + delay = constants.HelpChannels.deleted_idle_minutes * 60 + self.scheduler.schedule_later(delay, msg.channel.id, self.move_idle_channel(msg.channel)) async def is_empty(self, channel: discord.TextChannel) -> bool: """Return True if the most recent message in `channel` is the bot's `AVAILABLE_MSG`.""" @@ -752,8 +742,8 @@ class HelpChannels(Scheduler, commands.Cog): await self.remove_cooldown_role(member) else: # The member is still on a cooldown; re-schedule it for the remaining time. - remaining = cooldown - in_use_time.seconds - await self.schedule_cooldown_expiration(member, remaining) + delay = cooldown - in_use_time.seconds + self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member)) async def add_cooldown_role(self, member: discord.Member) -> None: """Add the help cooldown role to `member`.""" @@ -804,16 +794,11 @@ class HelpChannels(Scheduler, commands.Cog): # Cancel the existing task, if any. # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner). - self.cancel_task(member.id, ignore_missing=True) + if member.id in self.scheduler: + self.scheduler.cancel(member.id) - await self.schedule_cooldown_expiration(member, constants.HelpChannels.claim_minutes * 60) - - async def schedule_cooldown_expiration(self, member: discord.Member, seconds: int) -> None: - """Schedule the cooldown role for `member` to be removed after a duration of `seconds`.""" - log.trace(f"Scheduling removal of {member}'s ({member.id}) cooldown.") - - callback = self.remove_cooldown_role(member) - self.schedule_task(member.id, TaskData(seconds, callback)) + delay = constants.HelpChannels.claim_minutes * 60 + self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member)) async def send_available_message(self, channel: discord.TextChannel) -> None: """Send the available message by editing a dormant message or sending a new message.""" @@ -855,21 +840,6 @@ class HelpChannels(Scheduler, commands.Cog): return channel - async def _scheduled_task(self, data: TaskData) -> None: - """Await the `data.callback` coroutine after waiting for `data.wait_time` seconds.""" - try: - log.trace(f"Waiting {data.wait_time} seconds before awaiting callback.") - await asyncio.sleep(data.wait_time) - - # Use asyncio.shield to prevent callback from cancelling itself. - # The parent task (_scheduled_task) will still get cancelled. - log.trace("Done waiting; now awaiting the callback.") - await asyncio.shield(data.callback) - finally: - if inspect.iscoroutine(data.callback): - log.trace("Explicitly closing coroutine.") - data.callback.close() - def validate_config() -> None: """Raise a ValueError if the cog's config is invalid.""" -- cgit v1.2.3 From 23e663d5ff992d13a7685b44f09da0f21b390b0c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 20 Jun 2020 17:17:56 -0700 Subject: Update InfractionScheduler's scheduler to the new API --- bot/cogs/moderation/management.py | 4 ++-- bot/cogs/moderation/scheduler.py | 23 +++++++++-------------- bot/cogs/moderation/superstarify.py | 2 +- 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index c39c7f3bc..e87f3d7a4 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -135,11 +135,11 @@ class ModManagement(commands.Cog): if 'expires_at' in request_data: # A scheduled task should only exist if the old infraction wasn't permanent if old_infraction['expires_at']: - self.infractions_cog.cancel_task(new_infraction['id']) + self.infractions_cog.scheduler.cancel(new_infraction['id']) # If the infraction was not marked as permanent, schedule a new expiration task if request_data['expires_at']: - self.infractions_cog.schedule_task(new_infraction['id'], new_infraction) + self.infractions_cog.scheduler.schedule(new_infraction['id'], new_infraction) log_text += f""" Previous expiry: {old_infraction['expires_at'] or "Permanent"} diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index d75a72ddb..601e238c9 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -1,4 +1,3 @@ -import asyncio import logging import textwrap import typing as t @@ -23,13 +22,13 @@ from .utils import UserSnowflake log = logging.getLogger(__name__) -class InfractionScheduler(Scheduler): +class InfractionScheduler: """Handles the application, pardoning, and expiration of infractions.""" def __init__(self, bot: Bot, supported_infractions: t.Container[str]): - super().__init__() - self.bot = bot + self.scheduler = Scheduler(self.__class__.__name__) + self.bot.loop.create_task(self.reschedule_infractions(supported_infractions)) @property @@ -49,7 +48,7 @@ class InfractionScheduler(Scheduler): ) for infraction in infractions: if infraction["expires_at"] is not None and infraction["type"] in supported_infractions: - self.schedule_task(infraction["id"], infraction) + self.schedule_expiration(infraction) async def reapply_infraction( self, @@ -155,7 +154,7 @@ class InfractionScheduler(Scheduler): await action_coro if expiry: # Schedule the expiration of the infraction. - self.schedule_task(infraction["id"], infraction) + self.schedule_expiration(infraction) except discord.HTTPException as e: # Accordingly display that applying the infraction failed. confirm_msg = ":x: failed to apply" @@ -278,7 +277,7 @@ class InfractionScheduler(Scheduler): # Cancel pending expiration task. if infraction["expires_at"] is not None: - self.cancel_task(infraction["id"]) + self.scheduler.cancel(infraction["id"]) # Accordingly display whether the user was successfully notified via DM. dm_emoji = "" @@ -415,7 +414,7 @@ class InfractionScheduler(Scheduler): # Cancel the expiration task. if infraction["expires_at"] is not None: - self.cancel_task(infraction["id"]) + self.scheduler.cancel(infraction["id"]) # Send a log message to the mod log. if send_log: @@ -449,7 +448,7 @@ class InfractionScheduler(Scheduler): """ raise NotImplementedError - async def _scheduled_task(self, infraction: utils.Infraction) -> None: + def schedule_expiration(self, infraction: utils.Infraction) -> None: """ Marks an infraction expired after the delay from time of scheduling to time of expiration. @@ -457,8 +456,4 @@ class InfractionScheduler(Scheduler): expiration task is cancelled. """ expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) - await time.wait_until(expiry) - - # Because deactivate_infraction() explicitly cancels this scheduled task, it is shielded - # to avoid prematurely cancelling itself. - await asyncio.shield(self.deactivate_infraction(infraction)) + self.scheduler.schedule_at(expiry, infraction["id"], self.deactivate_infraction(infraction)) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 45a010f00..867de815a 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -146,7 +146,7 @@ class Superstarify(InfractionScheduler, Cog): log.debug(f"Changing nickname of {member} to {forced_nick}.") self.mod_log.ignore(constants.Event.member_update, member.id) await member.edit(nick=forced_nick, reason=reason) - self.schedule_task(id_, infraction) + self.schedule_expiration(infraction) # Send a DM to the user to notify them of their new infraction. await utils.notify_infraction( -- cgit v1.2.3 From 6fa8caed037b247a7c194f58a4635de7dae21fd2 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sun, 21 Jun 2020 13:51:17 +0200 Subject: Incidents: implement `make_username` helper The justification is to incorporate the `actioned_by` name into the username in some way, and so the logical thing to do is to abstract this process into a helper so that it can easily be adjusted in the future. For now, I've chosen to separate the names by a pipe. Discord webhook username cannot exceed 80 characters in length, and so we cap it at this length by default. This is seen as more of an edge-case, but it should be accounted for, as we're not joining two names. The `max_length` param is configurable primarily for testing purposes, it probably should never be passed explicitly. This commit also provides two tests for the function. --- bot/cogs/moderation/incidents.py | 24 ++++++++++++++++++++++++ tests/bot/cogs/moderation/test_incidents.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 040f2c0c8..2cce9b6fe 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -41,6 +41,30 @@ ALLOWED_ROLES: t.Set[int] = {Roles.moderators, Roles.admins, Roles.owners} ALL_SIGNALS: t.Set[str] = {signal.value for signal in Signal} +def make_username(reported_by: discord.Member, actioned_by: discord.Member, max_length: int = 80) -> str: + """ + Create a webhook-friendly username from the names of `reported_by` and `actioned_by`. + + If the resulting username length exceeds `max_length`, it will be capped at `max_length - 3` + and have 3 dots appended to the end. The default value is 80, which corresponds to the limit + Discord imposes on webhook username length. + + If the value of `max_length` is < 3, ValueError is raised. + """ + if max_length < 3: + raise ValueError(f"Maximum length cannot be less than 3: {max_length=}") + + username = f"{reported_by.name} | {actioned_by.name}" + log.trace(f"Generated webhook username: {username} (length: {len(username)})") + + if len(username) > max_length: + stop = max_length - 3 + username = f"{username[:stop]}..." + log.trace(f"Username capped at {max_length=}: {username}") + + return username + + def is_incident(message: discord.Message) -> bool: """True if `message` qualifies as an incident, False otherwise.""" conditions = ( diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 2fc9180cf..5700a5a35 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -68,6 +68,35 @@ mock_404 = discord.NotFound( ) +class TestMakeUsername(unittest.TestCase): + """Collection of tests for the `make_username` helper function.""" + + def test_make_username_raises(self): + """Raises `ValueError` on `max_length` < 3.""" + with self.assertRaises(ValueError): + incidents.make_username(MockMember(), MockMember(), max_length=2) + + def test_make_username_never_exceed_limit(self): + """ + The return string length is always less than or equal to `max_length`. + + For this test we pass `max_length=10` for convenience. The name of the first + user (`reported_by`) is always 1 character in length, but we generate names + for the `actioned_by` user starting at length 1 and up to length 20. + + Finally, we assert that the output length never exceeded 10 in total. + """ + user_a = MockMember(name="A") + + max_length = 10 + test_cases = (MockMember(name="B" * n) for n in range(1, 20)) + + for user_b in test_cases: + with self.subTest(user_a=user_a, user_b=user_b, max_length=max_length): + generated_username = incidents.make_username(user_a, user_b, max_length) + self.assertLessEqual(len(generated_username), max_length) + + @patch("bot.constants.Channels.incidents", 123) class TestIsIncident(unittest.TestCase): """ -- cgit v1.2.3 From a8d179d9b04f54b20c5e870bcfa85c78c42c8dca Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sun, 21 Jun 2020 14:21:18 +0200 Subject: Incidents: append `actioned_by` to webhook username Incident author and the moderator who actioned report are now passed through `make_username` to create the webhook username. Tests adjusted as appropriate. --- bot/cogs/moderation/incidents.py | 9 +++++---- tests/bot/cogs/moderation/test_incidents.py | 23 +++++++++++++++++------ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 2cce9b6fe..72cc4b26c 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -172,13 +172,14 @@ class Incidents(Cog): log.debug("Crawl task finished!") - async def archive(self, incident: discord.Message, outcome: Signal) -> bool: + async def archive(self, incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> bool: """ Relay `incident` to the #incidents-archive channel. The following pieces of information are relayed: * Incident message content (clean, pingless) - * Incident author name (as webhook author) + * Incident author name (as webhook username) + * Name of user who actioned the incident (appended to webhook username) * Incident author avatar (as webhook avatar) * Resolution signal (`outcome`) @@ -194,7 +195,7 @@ class Incidents(Cog): # Now relay the incident message: discord.Message = await webhook.send( content=incident.clean_content, # Clean content will prevent mentions from pinging - username=sub_clyde(incident.author.name), + username=sub_clyde(make_username(incident.author, actioned_by)), avatar_url=incident.author.avatar_url, wait=True, # This makes the method return the sent Message object ) @@ -259,7 +260,7 @@ class Incidents(Cog): log.debug("Reaction was valid, but no action is currently defined for it") return - relay_successful = await self.archive(incident, signal) + relay_successful = await self.archive(incident, signal, actioned_by=member) if not relay_successful: log.trace("Original message will not be deleted as we failed to relay it to the archive") return diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 5700a5a35..a811868e5 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -307,7 +307,9 @@ class TestArchive(TestIncidents): propagate out of the method, which is just as important. """ self.cog_instance.bot.fetch_webhook = AsyncMock(side_effect=mock_404) - self.assertFalse(await self.cog_instance.archive(incident=MockMessage(), outcome=MagicMock())) + + result = await self.cog_instance.archive(incident=MockMessage(), outcome=MagicMock(), actioned_by=MockMember()) + self.assertFalse(result) async def test_archive_relays_incident(self): """ @@ -332,12 +334,18 @@ class TestArchive(TestIncidents): author=MockUser(name="author_name", avatar_url="author_avatar"), id=123, ) - archive_return = await self.cog_instance.archive(incident, outcome=MagicMock(value="A")) + + with patch("bot.cogs.moderation.incidents.make_username", MagicMock(return_value="generated_username")): + archive_return = await self.cog_instance.archive( + incident=incident, + outcome=MagicMock(value="A"), + actioned_by=MockMember(name="moderator"), + ) # Check that the webhook was dispatched correctly webhook.send.assert_called_once_with( content="pingless message", - username="author_name", + username="generated_username", avatar_url="author_avatar", wait=True, ) @@ -354,7 +362,8 @@ class TestArchive(TestIncidents): Discord will reject any webhook with "clyde" in the username field, as it impersonates the official Clyde bot. Since we do not control what the username will be (the incident - author name is used), we must ensure the name is cleansed, otherwise the relay may fail. + author name, and actioning moderator names are used), we must ensure the name is cleansed, + otherwise the relay may fail. This test assumes the username is passed as a kwarg. If this test fails, please review whether the passed argument is being retrieved correctly. @@ -362,9 +371,11 @@ class TestArchive(TestIncidents): webhook = MockAsyncWebhook() self.cog_instance.bot.fetch_webhook = AsyncMock(return_value=webhook) - message_from_clyde = MockMessage(author=MockUser(name="clyde the great")) - await self.cog_instance.archive(message_from_clyde, MagicMock(incidents.Signal)) + # The `make_username` helper will return a string with "clyde" in it + with patch("bot.cogs.moderation.incidents.make_username", MagicMock(return_value="clyde the great")): + await self.cog_instance.archive(MockMessage(), MagicMock(incidents.Signal), MockMember()) + # Assert that the "clyde" was never passed to `send` self.assertNotIn("clyde", webhook.send.call_args.kwargs["username"]) -- cgit v1.2.3 From 58d20203870f293de9410db4bf0e602696d04c2c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 23 Jun 2020 23:52:50 -0700 Subject: Scheduler: close coroutine if task ID already exists This prevents unawaited coroutine warnings. --- bot/utils/scheduling.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index f5308059a..4e99db76c 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -20,11 +20,17 @@ class Scheduler: return task_id in self._scheduled_tasks def schedule(self, task_id: t.Hashable, coroutine: t.Coroutine) -> None: - """Schedule the execution of a coroutine.""" + """ + Schedule the execution of a coroutine. + + If a task with `task_id` already exists, close `coroutine` instead of scheduling it. + This prevents unawaited coroutine warnings. + """ self._log.trace(f"Scheduling task #{task_id}...") if task_id in self._scheduled_tasks: self._log.debug(f"Did not schedule task #{task_id}; task was already scheduled.") + coroutine.close() return task = asyncio.create_task(coroutine, name=f"{self.name}_{task_id}") -- cgit v1.2.3 From 52df57f53ccec7467afaa64535697aa9cd3a0740 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 24 Jun 2020 14:38:10 +0300 Subject: Mod Utils: Remove unnecessary line splitting on embed footer adding --- bot/cogs/moderation/utils.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 2acaf37f9..5df282f80 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -170,9 +170,7 @@ async def notify_infraction( embed.url = RULES_URL if infr_type in APPEALABLE_INFRACTIONS: - embed.set_footer( - text=INFRACTION_APPEAL_FOOTER - ) + embed.set_footer(text=INFRACTION_APPEAL_FOOTER) return await send_private_embed(user, embed) -- cgit v1.2.3 From 5f0490aad5a8d22a5f05dc6debdb3485a0ed9671 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 24 Jun 2020 14:45:51 +0300 Subject: Mod Utils Tests: Move INFRACTION_DESCRIPTION_TEMPLATE to tests file --- bot/cogs/moderation/utils.py | 6 ------ tests/bot/cogs/moderation/test_utils.py | 16 +++++++++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 5df282f80..104baf528 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -34,12 +34,6 @@ INFRACTION_TITLE = f"Please review our rules over at {RULES_URL}" INFRACTION_APPEAL_FOOTER = f"To appeal this infraction, send an e-mail to {APPEAL_EMAIL}" INFRACTION_AUTHOR_NAME = "Infraction information" -INFRACTION_DESCRIPTION_TEMPLATE = ( - "\n**Type:** {type}\n" - "**Expires:** {expires}\n" - "**Reason:** {reason}\n" -) - async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]: """ diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 77f926a48..dde5b438d 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -10,6 +10,12 @@ from bot.cogs.moderation import utils from bot.constants import Colours, Icons from tests.helpers import MockBot, MockContext, MockMember, MockUser +INFRACTION_DESCRIPTION_TEMPLATE = ( + "\n**Type:** {type}\n" + "**Expires:** {expires}\n" + "**Reason:** {reason}\n" +) + class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): """Tests Moderation utils.""" @@ -83,7 +89,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "args": (self.user, "ban", "2020-02-26 09:20 (23 hours and 59 minutes)"), "expected_output": Embed( title=utils.INFRACTION_TITLE, - description=textwrap.shorten(utils.INFRACTION_DESCRIPTION_TEMPLATE.format( + description=textwrap.shorten(INFRACTION_DESCRIPTION_TEMPLATE.format( type="Ban", expires="2020-02-26 09:20 (23 hours and 59 minutes)", reason="No reason provided." @@ -101,7 +107,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "args": (self.user, "warning", None, "Test reason."), "expected_output": Embed( title=utils.INFRACTION_TITLE, - description=textwrap.shorten(utils.INFRACTION_DESCRIPTION_TEMPLATE.format( + description=textwrap.shorten(INFRACTION_DESCRIPTION_TEMPLATE.format( type="Warning", expires="N/A", reason="Test reason." @@ -119,7 +125,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "args": (self.user, "note", None, None, Icons.defcon_denied), "expected_output": Embed( title=utils.INFRACTION_TITLE, - description=textwrap.shorten(utils.INFRACTION_DESCRIPTION_TEMPLATE.format( + description=textwrap.shorten(INFRACTION_DESCRIPTION_TEMPLATE.format( type="Note", expires="N/A", reason="No reason provided." @@ -137,7 +143,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "args": (self.user, "mute", "2020-02-26 09:20 (23 hours and 59 minutes)", "Test", Icons.defcon_denied), "expected_output": Embed( title=utils.INFRACTION_TITLE, - description=textwrap.shorten(utils.INFRACTION_DESCRIPTION_TEMPLATE.format( + description=textwrap.shorten(INFRACTION_DESCRIPTION_TEMPLATE.format( type="Mute", expires="2020-02-26 09:20 (23 hours and 59 minutes)", reason="Test" @@ -155,7 +161,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "args": (self.user, "mute", None, "foo bar" * 4000, Icons.defcon_denied), "expected_output": Embed( title=utils.INFRACTION_TITLE, - description=textwrap.shorten(utils.INFRACTION_DESCRIPTION_TEMPLATE.format( + description=textwrap.shorten(INFRACTION_DESCRIPTION_TEMPLATE.format( type="Mute", expires="N/A", reason="foo bar" * 4000 -- cgit v1.2.3 From 9a80e9cf2fea30f9760f5fd0a2d2f21ad5c828b4 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 24 Jun 2020 15:01:05 +0300 Subject: Mod Utils Tests: Move some test cases to `namedtuple` --- tests/bot/cogs/moderation/test_utils.py | 95 ++++++++++----------------------- 1 file changed, 29 insertions(+), 66 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index dde5b438d..e54c0d240 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -1,5 +1,6 @@ import textwrap import unittest +from collections import namedtuple from datetime import datetime from unittest.mock import AsyncMock, MagicMock, call, patch @@ -32,29 +33,15 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): A message should be sent to the context indicating a user already has an infraction, if that's the case. """ + test_case = namedtuple("test_case", ["get_return_value", "expected_output", "infraction_nr", "send_msg"]) test_cases = [ - { - "get_return_value": [], - "expected_output": None, - "infraction_nr": None, - "send_msg": True - }, - { - "get_return_value": [{"id": 123987}], - "expected_output": {"id": 123987}, - "infraction_nr": "123987", - "send_msg": False - }, - { - "get_return_value": [{"id": 123987}], - "expected_output": {"id": 123987}, - "infraction_nr": "123987", - "send_msg": True - } + test_case([], None, None, True), + test_case([{"id": 123987}], {"id": 123987}, "123987", False), + test_case([{"id": 123987}], {"id": 123987}, "123987", True) ] for case in test_cases: - with self.subTest(return_value=case["get_return_value"], expected=case["expected_output"]): + with self.subTest(return_value=case.get_return_value, expected=case.expected_output): self.bot.api_client.get.reset_mock() self.ctx.send.reset_mock() @@ -64,15 +51,15 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "user__id": str(self.member.id) } - self.bot.api_client.get.return_value = case["get_return_value"] + self.bot.api_client.get.return_value = case.get_return_value - result = await utils.get_active_infraction(self.ctx, self.member, "ban", send_msg=case["send_msg"]) - self.assertEqual(result, case["expected_output"]) + result = await utils.get_active_infraction(self.ctx, self.member, "ban", send_msg=case.send_msg) + self.assertEqual(result, case.expected_output) self.bot.api_client.get.assert_awaited_once_with("bot/infractions", params=params) - if case["send_msg"] and case["get_return_value"]: + if case.send_msg and case.get_return_value: self.ctx.send.assert_awaited_once() - self.assertTrue(case["infraction_nr"] in self.ctx.send.call_args[0][0]) + self.assertTrue(case.infraction_nr in self.ctx.send.call_args[0][0]) self.assertTrue("ban" in self.ctx.send.call_args[0][0]) else: self.ctx.send.assert_not_awaited() @@ -199,43 +186,33 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): @patch("bot.cogs.moderation.utils.send_private_embed") async def test_notify_pardon(self, send_private_embed_mock): """Should send an embed of a certain format as a DM and return `True` if DM successful.""" + test_case = namedtuple("test_case", ["args", "icon", "send_result"]) test_cases = [ - { - "args": (self.user, "Test title", "Example content"), - "icon": Icons.user_verified, - "send_result": True - }, - { - "args": (self.user, "Test title", "Example content", Icons.user_update), - "icon": Icons.user_update, - "send_result": False - } + test_case((self.user, "Test title", "Example content"), Icons.user_verified, True), + test_case((self.user, "Test title", "Example content", Icons.user_update), Icons.user_update, False) ] for case in test_cases: - args = case["args"] - send = case["send_result"] - expected = Embed( description="Example content", colour=Colours.soft_green ).set_author( name="Test title", - icon_url=case["icon"] + icon_url=case.icon ) - with self.subTest(args=args, expected=expected): + with self.subTest(args=case.args, expected=expected): send_private_embed_mock.reset_mock() - send_private_embed_mock.return_value = send + send_private_embed_mock.return_value = case.send_result - result = await utils.notify_pardon(*args) - self.assertEqual(send, result) + result = await utils.notify_pardon(*case.args) + self.assertEqual(case.send_result, result) embed = send_private_embed_mock.call_args[0][1] self.assertEqual(embed.to_dict(), expected.to_dict()) - send_private_embed_mock.assert_awaited_once_with(args[0], embed) + send_private_embed_mock.assert_awaited_once_with(case.args[0], embed) @patch("bot.cogs.moderation.utils.log") async def test_post_user(self, log_mock): @@ -316,37 +293,23 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): """Should DM the user and return `True` on success or `False` on failure.""" embed = Embed(title="Test", description="Test val") + test_case = namedtuple("test_case", ["expected_output", "raised_exception"]) test_cases = [ - { - "expected_output": True, - "raised_exception": None - }, - { - "expected_output": False, - "raised_exception": HTTPException(AsyncMock(), AsyncMock()) - }, - { - "expected_output": False, - "raised_exception": Forbidden(AsyncMock(), AsyncMock()) - }, - { - "expected_output": False, - "raised_exception": NotFound(AsyncMock(), AsyncMock()) - } + test_case(True, None), + test_case(False, HTTPException(AsyncMock(), AsyncMock())), + test_case(False, Forbidden(AsyncMock(), AsyncMock())), + test_case(False, NotFound(AsyncMock(), AsyncMock())) ] for case in test_cases: - expected = case["expected_output"] - raised = case["raised_exception"] - - with self.subTest(expected=expected, raised=raised): + with self.subTest(expected=case.expected_output, raised=case.raised_exception): self.user.send.reset_mock(side_effect=True) - self.user.send.side_effect = raised + self.user.send.side_effect = case.raised_exception result = await utils.send_private_embed(self.user, embed) - self.assertEqual(result, expected) - if expected: + self.assertEqual(result, case.expected_output) + if case.expected_output: self.user.send.assert_awaited_once_with(embed=embed) -- cgit v1.2.3 From 024633a470d86d84189c714d194e750507f47d47 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 24 Jun 2020 15:03:38 +0300 Subject: Mod Utils Tests: Change `True` assert to `In` assert for message check --- tests/bot/cogs/moderation/test_utils.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index e54c0d240..aaa0861e5 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -59,8 +59,9 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): if case.send_msg and case.get_return_value: self.ctx.send.assert_awaited_once() - self.assertTrue(case.infraction_nr in self.ctx.send.call_args[0][0]) - self.assertTrue("ban" in self.ctx.send.call_args[0][0]) + sent_message = self.ctx.send.call_args[0][0] + self.assertIn(case.infraction_nr, sent_message) + self.assertIn("ban", sent_message) else: self.ctx.send.assert_not_awaited() -- cgit v1.2.3 From c205f6303a6533cee6cb02cf85dba30b43e0630f Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 24 Jun 2020 15:04:31 +0300 Subject: Mod Utils Tests: Remove unnecessary `user` from test name --- tests/bot/cogs/moderation/test_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index aaa0861e5..a104b969a 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -27,7 +27,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): self.user = MockUser(id=1234) self.ctx = MockContext(bot=self.bot, author=self.member) - async def test_user_get_active_infraction(self): + async def test_get_active_infraction(self): """ Should request the API for active infractions and return infraction if the user has one or `None` otherwise. -- cgit v1.2.3 From 2123cdb2f7f438491093ef0195cedd432466f9b8 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 24 Jun 2020 15:13:38 +0300 Subject: Remove case variable definitions in `test_notify_infraction` --- tests/bot/cogs/moderation/test_utils.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index a104b969a..7e8e6d9f0 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -166,23 +166,19 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): ] for case in test_cases: - args = case["args"] - expected = case["expected_output"] - send = case["send_result"] - - with self.subTest(args=args, expected=expected, send=send): + with self.subTest(args=case["args"], expected=case["expected_output"], send=case["send_result"]): send_private_embed_mock.reset_mock() - send_private_embed_mock.return_value = send - result = await utils.notify_infraction(*args) + send_private_embed_mock.return_value = case["send_result"] + result = await utils.notify_infraction(*case["args"]) - self.assertEqual(send, result) + self.assertEqual(case["send_result"], result) embed = send_private_embed_mock.call_args[0][1] - self.assertEqual(embed.to_dict(), expected.to_dict()) + self.assertEqual(embed.to_dict(), case["expected"].to_dict()) - send_private_embed_mock.assert_awaited_once_with(args[0], embed) + send_private_embed_mock.assert_awaited_once_with(case["args"][0], embed) @patch("bot.cogs.moderation.utils.send_private_embed") async def test_notify_pardon(self, send_private_embed_mock): -- cgit v1.2.3 From efde49c677650599b097955a1606dae0d122c97d Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 24 Jun 2020 15:17:53 +0300 Subject: Sync keys, variable names and kwargs in `test_post_user` --- tests/bot/cogs/moderation/test_utils.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 7e8e6d9f0..b434737ea 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -256,32 +256,32 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): ] for case in test_cases: - test_user = case["user"] - expected = case["post_result"] - error = case["raise_error"] + user = case["user"] + post_result = case["post_result"] + raise_error = case["raise_error"] payload = case["payload"] - with self.subTest(user=test_user, result=expected, error=error, payload=payload): + with self.subTest(user=user, post_result=post_result, raise_error=raise_error, payload=payload): log_mock.reset_mock() self.bot.api_client.post.reset_mock(side_effect=True) - self.ctx.bot.api_client.post.return_value = expected + self.ctx.bot.api_client.post.return_value = post_result - self.ctx.bot.api_client.post.side_effect = error + self.ctx.bot.api_client.post.side_effect = raise_error - result = await utils.post_user(self.ctx, test_user) + result = await utils.post_user(self.ctx, user) - if error: + if raise_error: self.assertIsNone(result) else: - self.assertEqual(result, expected) + self.assertEqual(result, post_result) - if not error: + if not raise_error: self.bot.api_client.post.assert_awaited_once_with("bot/users", json=payload) else: self.ctx.send.assert_awaited_once() - self.assertTrue(str(error.status) in self.ctx.send.call_args[0][0]) + self.assertTrue(str(raise_error.status) in self.ctx.send.call_args[0][0]) - if isinstance(test_user, MagicMock): + if isinstance(user, MagicMock): log_mock.debug.assert_called_once() else: log_mock.debug.assert_not_called() -- cgit v1.2.3 From 3d4c50c498647a6537eef747e84690f8852d388c Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 24 Jun 2020 15:18:49 +0300 Subject: Replace `True` test with `In` test on `test_post_user` --- tests/bot/cogs/moderation/test_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index b434737ea..5be703bc6 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -279,7 +279,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): self.bot.api_client.post.assert_awaited_once_with("bot/users", json=payload) else: self.ctx.send.assert_awaited_once() - self.assertTrue(str(raise_error.status) in self.ctx.send.call_args[0][0]) + self.assertIn(str(raise_error.status), self.ctx.send.call_args[0][0]) if isinstance(user, MagicMock): log_mock.debug.assert_called_once() -- cgit v1.2.3 From 4430e590ece503c262419324e2bc47dbaa5823d2 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 24 Jun 2020 15:21:00 +0300 Subject: Merge 2 if-else branches is `test_post_user` --- tests/bot/cogs/moderation/test_utils.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 5be703bc6..f4c634936 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -272,14 +272,11 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): if raise_error: self.assertIsNone(result) + self.ctx.send.assert_awaited_once() + self.assertIn(str(raise_error.status), self.ctx.send.call_args[0][0]) else: self.assertEqual(result, post_result) - - if not raise_error: self.bot.api_client.post.assert_awaited_once_with("bot/users", json=payload) - else: - self.ctx.send.assert_awaited_once() - self.assertIn(str(raise_error.status), self.ctx.send.call_args[0][0]) if isinstance(user, MagicMock): log_mock.debug.assert_called_once() -- cgit v1.2.3 From 136ebd22a73318620e8a3fa6136d28f5390ddeaf Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 24 Jun 2020 15:22:18 +0300 Subject: Remove unnecessary `log.debug` assert in `test_post_user` --- tests/bot/cogs/moderation/test_utils.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index f4c634936..e6eac6831 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -211,8 +211,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): send_private_embed_mock.assert_awaited_once_with(case.args[0], embed) - @patch("bot.cogs.moderation.utils.log") - async def test_post_user(self, log_mock): + async def test_post_user(self): """Should POST a new user and return the response if successful or otherwise send an error message.""" user = MockUser(discriminator=5678, id=1234, name="Test user") some_mock = MagicMock(discriminator=3333) @@ -262,7 +261,6 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): payload = case["payload"] with self.subTest(user=user, post_result=post_result, raise_error=raise_error, payload=payload): - log_mock.reset_mock() self.bot.api_client.post.reset_mock(side_effect=True) self.ctx.bot.api_client.post.return_value = post_result @@ -278,11 +276,6 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(result, post_result) self.bot.api_client.post.assert_awaited_once_with("bot/users", json=payload) - if isinstance(user, MagicMock): - log_mock.debug.assert_called_once() - else: - log_mock.debug.assert_not_called() - async def test_send_private_embed(self): """Should DM the user and return `True` on success or `False` on failure.""" embed = Embed(title="Test", description="Test val") -- cgit v1.2.3 From b2a70712ac4aaa067edfbb7a8940cf9b78f44e53 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 24 Jun 2020 15:28:22 +0300 Subject: Add other parameters to `test_post_user` `not_user` mock --- tests/bot/cogs/moderation/test_utils.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index e6eac6831..f89f41d25 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -214,7 +214,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): async def test_post_user(self): """Should POST a new user and return the response if successful or otherwise send an error message.""" user = MockUser(discriminator=5678, id=1234, name="Test user") - some_mock = MagicMock(discriminator=3333) + not_user = MagicMock(discriminator=3333, id=5678, name="Wrong user") test_cases = [ { "user": user, @@ -241,14 +241,14 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): } }, { - "user": some_mock, + "user": not_user, "post_result": "bar", "raise_error": None, "payload": { - "discriminator": some_mock.discriminator, - "id": some_mock.id, + "discriminator": not_user.discriminator, + "id": not_user.id, "in_guild": False, - "name": some_mock.name, + "name": not_user.name, "roles": [] } } -- cgit v1.2.3 From 9b1538878221c966b62dc5c9d0be2af1fd475325 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 24 Jun 2020 15:29:52 +0300 Subject: Fix test case key name in `test_notify_infraction` --- tests/bot/cogs/moderation/test_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index f89f41d25..c4d0d6f16 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -176,7 +176,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): embed = send_private_embed_mock.call_args[0][1] - self.assertEqual(embed.to_dict(), case["expected"].to_dict()) + self.assertEqual(embed.to_dict(), case["expected_output"].to_dict()) send_private_embed_mock.assert_awaited_once_with(case["args"][0], embed) -- cgit v1.2.3 From 7b89d2cfad91cc9a56565ebc7700f4858814f149 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 24 Jun 2020 15:36:34 +0300 Subject: Move infraction description template back to main file, apply it there --- bot/cogs/moderation/utils.py | 18 +++++++++++++----- tests/bot/cogs/moderation/test_utils.py | 16 +++++----------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 104baf528..cbef3420a 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -34,6 +34,12 @@ INFRACTION_TITLE = f"Please review our rules over at {RULES_URL}" INFRACTION_APPEAL_FOOTER = f"To appeal this infraction, send an e-mail to {APPEAL_EMAIL}" INFRACTION_AUTHOR_NAME = "Infraction information" +INFRACTION_DESCRIPTION_TEMPLATE = ( + "\n**Type:** {type}\n" + "**Expires:** {expires}\n" + "**Reason:** {reason}\n" +) + async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]: """ @@ -148,11 +154,13 @@ async def notify_infraction( """DM a user about their new infraction and return True if the DM is successful.""" log.trace(f"Sending {user} a DM about their {infr_type} infraction.") - text = textwrap.dedent(f""" - **Type:** {infr_type.capitalize()} - **Expires:** {expires_at or "N/A"} - **Reason:** {reason or "No reason provided."} - """) + text = textwrap.dedent( + INFRACTION_DESCRIPTION_TEMPLATE.format( + type=infr_type.capitalize(), + expires=expires_at or "N/A", + reason=reason or "No reason provided." + ) + ) embed = discord.Embed( description=textwrap.shorten(text, width=2048, placeholder="..."), diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index c4d0d6f16..c35c0edf5 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -11,12 +11,6 @@ from bot.cogs.moderation import utils from bot.constants import Colours, Icons from tests.helpers import MockBot, MockContext, MockMember, MockUser -INFRACTION_DESCRIPTION_TEMPLATE = ( - "\n**Type:** {type}\n" - "**Expires:** {expires}\n" - "**Reason:** {reason}\n" -) - class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): """Tests Moderation utils.""" @@ -77,7 +71,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "args": (self.user, "ban", "2020-02-26 09:20 (23 hours and 59 minutes)"), "expected_output": Embed( title=utils.INFRACTION_TITLE, - description=textwrap.shorten(INFRACTION_DESCRIPTION_TEMPLATE.format( + description=textwrap.shorten(utils.INFRACTION_DESCRIPTION_TEMPLATE.format( type="Ban", expires="2020-02-26 09:20 (23 hours and 59 minutes)", reason="No reason provided." @@ -95,7 +89,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "args": (self.user, "warning", None, "Test reason."), "expected_output": Embed( title=utils.INFRACTION_TITLE, - description=textwrap.shorten(INFRACTION_DESCRIPTION_TEMPLATE.format( + description=textwrap.shorten(utils.INFRACTION_DESCRIPTION_TEMPLATE.format( type="Warning", expires="N/A", reason="Test reason." @@ -113,7 +107,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "args": (self.user, "note", None, None, Icons.defcon_denied), "expected_output": Embed( title=utils.INFRACTION_TITLE, - description=textwrap.shorten(INFRACTION_DESCRIPTION_TEMPLATE.format( + description=textwrap.shorten(utils.INFRACTION_DESCRIPTION_TEMPLATE.format( type="Note", expires="N/A", reason="No reason provided." @@ -131,7 +125,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "args": (self.user, "mute", "2020-02-26 09:20 (23 hours and 59 minutes)", "Test", Icons.defcon_denied), "expected_output": Embed( title=utils.INFRACTION_TITLE, - description=textwrap.shorten(INFRACTION_DESCRIPTION_TEMPLATE.format( + description=textwrap.shorten(utils.INFRACTION_DESCRIPTION_TEMPLATE.format( type="Mute", expires="2020-02-26 09:20 (23 hours and 59 minutes)", reason="Test" @@ -149,7 +143,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "args": (self.user, "mute", None, "foo bar" * 4000, Icons.defcon_denied), "expected_output": Embed( title=utils.INFRACTION_TITLE, - description=textwrap.shorten(INFRACTION_DESCRIPTION_TEMPLATE.format( + description=textwrap.shorten(utils.INFRACTION_DESCRIPTION_TEMPLATE.format( type="Mute", expires="N/A", reason="foo bar" * 4000 -- cgit v1.2.3 From 5aaa7df5e72c5063b1eb59fe71c7dea745f18f48 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 24 Jun 2020 15:39:08 +0300 Subject: Remove unnecessary `textwrap.dedent` in `notify_infraction` --- bot/cogs/moderation/utils.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index cbef3420a..8b36210be 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -154,12 +154,10 @@ async def notify_infraction( """DM a user about their new infraction and return True if the DM is successful.""" log.trace(f"Sending {user} a DM about their {infr_type} infraction.") - text = textwrap.dedent( - INFRACTION_DESCRIPTION_TEMPLATE.format( - type=infr_type.capitalize(), - expires=expires_at or "N/A", - reason=reason or "No reason provided." - ) + text = INFRACTION_DESCRIPTION_TEMPLATE.format( + type=infr_type.capitalize(), + expires=expires_at or "N/A", + reason=reason or "No reason provided." ) embed = discord.Embed( -- cgit v1.2.3 From 0c9fc3a1bbaf590d7ccf8737ffffcfb4b1b5b1b8 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 24 Jun 2020 15:40:46 +0300 Subject: Reorder tests order to match with original file --- tests/bot/cogs/moderation/test_utils.py | 130 ++++++++++++++++---------------- 1 file changed, 65 insertions(+), 65 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index c35c0edf5..0f6f9c469 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -21,6 +21,71 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): self.user = MockUser(id=1234) self.ctx = MockContext(bot=self.bot, author=self.member) + async def test_post_user(self): + """Should POST a new user and return the response if successful or otherwise send an error message.""" + user = MockUser(discriminator=5678, id=1234, name="Test user") + not_user = MagicMock(discriminator=3333, id=5678, name="Wrong user") + test_cases = [ + { + "user": user, + "post_result": "bar", + "raise_error": None, + "payload": { + "discriminator": 5678, + "id": self.user.id, + "in_guild": False, + "name": "Test user", + "roles": [] + } + }, + { + "user": self.member, + "post_result": "foo", + "raise_error": ResponseCodeError(MagicMock(status=400), "foo"), + "payload": { + "discriminator": 0, + "id": self.member.id, + "in_guild": False, + "name": "Name unknown", + "roles": [] + } + }, + { + "user": not_user, + "post_result": "bar", + "raise_error": None, + "payload": { + "discriminator": not_user.discriminator, + "id": not_user.id, + "in_guild": False, + "name": not_user.name, + "roles": [] + } + } + ] + + for case in test_cases: + user = case["user"] + post_result = case["post_result"] + raise_error = case["raise_error"] + payload = case["payload"] + + with self.subTest(user=user, post_result=post_result, raise_error=raise_error, payload=payload): + self.bot.api_client.post.reset_mock(side_effect=True) + self.ctx.bot.api_client.post.return_value = post_result + + self.ctx.bot.api_client.post.side_effect = raise_error + + result = await utils.post_user(self.ctx, user) + + if raise_error: + self.assertIsNone(result) + self.ctx.send.assert_awaited_once() + self.assertIn(str(raise_error.status), self.ctx.send.call_args[0][0]) + else: + self.assertEqual(result, post_result) + self.bot.api_client.post.assert_awaited_once_with("bot/users", json=payload) + async def test_get_active_infraction(self): """ Should request the API for active infractions and return infraction if the user has one or `None` otherwise. @@ -205,71 +270,6 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): send_private_embed_mock.assert_awaited_once_with(case.args[0], embed) - async def test_post_user(self): - """Should POST a new user and return the response if successful or otherwise send an error message.""" - user = MockUser(discriminator=5678, id=1234, name="Test user") - not_user = MagicMock(discriminator=3333, id=5678, name="Wrong user") - test_cases = [ - { - "user": user, - "post_result": "bar", - "raise_error": None, - "payload": { - "discriminator": 5678, - "id": self.user.id, - "in_guild": False, - "name": "Test user", - "roles": [] - } - }, - { - "user": self.member, - "post_result": "foo", - "raise_error": ResponseCodeError(MagicMock(status=400), "foo"), - "payload": { - "discriminator": 0, - "id": self.member.id, - "in_guild": False, - "name": "Name unknown", - "roles": [] - } - }, - { - "user": not_user, - "post_result": "bar", - "raise_error": None, - "payload": { - "discriminator": not_user.discriminator, - "id": not_user.id, - "in_guild": False, - "name": not_user.name, - "roles": [] - } - } - ] - - for case in test_cases: - user = case["user"] - post_result = case["post_result"] - raise_error = case["raise_error"] - payload = case["payload"] - - with self.subTest(user=user, post_result=post_result, raise_error=raise_error, payload=payload): - self.bot.api_client.post.reset_mock(side_effect=True) - self.ctx.bot.api_client.post.return_value = post_result - - self.ctx.bot.api_client.post.side_effect = raise_error - - result = await utils.post_user(self.ctx, user) - - if raise_error: - self.assertIsNone(result) - self.ctx.send.assert_awaited_once() - self.assertIn(str(raise_error.status), self.ctx.send.call_args[0][0]) - else: - self.assertEqual(result, post_result) - self.bot.api_client.post.assert_awaited_once_with("bot/users", json=payload) - async def test_send_private_embed(self): """Should DM the user and return `True` on success or `False` on failure.""" embed = Embed(title="Test", description="Test val") -- cgit v1.2.3 From bc6817536a7db4242cfa725ce809ced45f7cb556 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 24 Jun 2020 16:46:14 -0700 Subject: Scheduler: remove duplicate dict delete The task is already popped from the dict, so there is no need to delete it afterwards. --- bot/utils/scheduling.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 4e99db76c..4110598d5 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -64,7 +64,6 @@ class Scheduler: except KeyError: self._log.warning(f"Failed to unschedule {task_id} (no task found).") else: - del self._scheduled_tasks[task_id] task.cancel() self._log.debug(f"Unscheduled task #{task_id} {id(task)}.") -- cgit v1.2.3 From 1a812b7c3ef7048d8058c8c5a7d5e3afd0f86317 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 25 Jun 2020 11:48:02 +0300 Subject: Remove unnecessary if statement from send_private_embed test --- tests/bot/cogs/moderation/test_utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 0f6f9c469..029719669 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -290,8 +290,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): result = await utils.send_private_embed(self.user, embed) self.assertEqual(result, case.expected_output) - if case.expected_output: - self.user.send.assert_awaited_once_with(embed=embed) + self.user.send.assert_awaited_once_with(embed=embed) class TestPostInfraction(unittest.IsolatedAsyncioTestCase): -- cgit v1.2.3 From e09307e0f8f570279271c99525e0cde6cfa84d5b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 25 Jun 2020 11:51:19 -0700 Subject: Scheduler: only close unawaited coroutines The coroutine may cancel the scheduled task, which would also trigger the finally block. The coroutine isn't necessarily finished when it cancels the task, so it shouldn't be closed in this case. --- bot/utils/scheduling.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 4110598d5..cf2a1f110 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -1,5 +1,6 @@ import asyncio import contextlib +import inspect import logging import typing as t from datetime import datetime @@ -87,8 +88,11 @@ class Scheduler: finally: # Close it to prevent unawaited coroutine warnings, # which would happen if the task was cancelled during the sleep. - self._log.trace("Explicitly closing the coroutine.") - coroutine.close() + # Only close it if it's not been awaited yet. This check is important because the + # coroutine may cancel this task, which would also trigger the finally block. + if inspect.getcoroutinestate(coroutine) == "CORO_CREATED": + self._log.trace("Explicitly closing the coroutine.") + coroutine.close() def _task_done_callback(self, task_id: t.Hashable, done_task: asyncio.Task) -> None: """ -- cgit v1.2.3 From 94017fdf0e3c9805e3ead81823f3870d3834edd5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 27 Jun 2020 16:09:54 -0700 Subject: Code block: rename BadLanguage attributes The `has_` prefix it clarifies that they're booleans. Co-authored-by: Numerlor --- bot/cogs/codeblock/instructions.py | 4 ++-- bot/cogs/codeblock/parsing.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index c9db80deb..4ea5ca094 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -100,11 +100,11 @@ def _get_bad_lang_message(content: str) -> Optional[str]: lines = [] language = info.language - if info.leading_spaces: + if info.has_leading_spaces: log.trace("Language specifier was preceded by a space.") lines.append(f"Make sure there are no spaces between the back ticks and `{language}`.") - if not info.terminal_newline: + if not info.has_terminal_newline: log.trace("Language specifier was not followed by a newline.") lines.append( f"Make sure you put your code on a new line following `{language}`. " diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 89f8111fc..73b6a874e 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -60,8 +60,8 @@ class BadLanguage(NamedTuple): """Parsed information about a poorly formatted language specifier.""" language: str - leading_spaces: bool - terminal_newline: bool + has_leading_spaces: bool + has_terminal_newline: bool def find_code_blocks(message: str) -> Optional[Sequence[CodeBlock]]: -- cgit v1.2.3 From 8f37b6c5aef955bb4fab4f30cdcbea6c3c4888c2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 27 Jun 2020 16:12:14 -0700 Subject: Code block: make PY_LANG_CODES more visible The declaration was a bit hidden between the two regular expressions. --- bot/cogs/codeblock/parsing.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 73b6a874e..31cbd09b9 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -11,6 +11,7 @@ from bot.utils import has_lines log = logging.getLogger(__name__) BACKTICK = "`" +PY_LANG_CODES = ("python", "py") # Order is important; "py" is second cause it's a subset. _TICKS = { BACKTICK, "'", @@ -24,6 +25,7 @@ _TICKS = { "\u2033", # DOUBLE PRIME "\u3003", # VERTICAL KANA REPEAT MARK UPPER HALF } + _RE_CODE_BLOCK = re.compile( fr""" (?P @@ -37,7 +39,6 @@ _RE_CODE_BLOCK = re.compile( re.DOTALL | re.VERBOSE ) -PY_LANG_CODES = ("python", "py") # Order is important; "py" is second cause it's a subset. _RE_LANGUAGE = re.compile( fr""" ^(?P\s+)? # Optionally match leading spaces from the beginning. -- cgit v1.2.3 From b209997a294c8dd07f08e9f2e3ffdb5afc265285 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 27 Jun 2020 16:16:25 -0700 Subject: Code block: use config constant for cooldown --- bot/cogs/codeblock/cog.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 6032e911c..2576be966 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -83,12 +83,14 @@ class CodeBlockCog(Cog, name="Code Block"): def is_on_cooldown(self, channel: discord.TextChannel) -> bool: """ - Return True if an embed was sent for `channel` in the last 300 seconds. + Return True if an embed was sent too recently for `channel`. + The cooldown is configured by `constants.CodeBlock.cooldown_seconds`. Note: only channels in the `channel_cooldowns` have cooldowns enabled. """ log.trace(f"Checking if #{channel} is on cooldown.") - return (time.time() - self.channel_cooldowns.get(channel.id, 0)) < 300 + cooldown = constants.CodeBlock.cooldown_seconds + return (time.time() - self.channel_cooldowns.get(channel.id, 0)) < cooldown def is_valid_channel(self, channel: discord.TextChannel) -> bool: """Return True if `channel` is a help channel, may be on a cooldown, or is whitelisted.""" -- cgit v1.2.3 From 50757197956e3bba99dc845cdc264d759cbc8a71 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 27 Jun 2020 16:17:49 -0700 Subject: Code block: simplify channel cooldown dict creation --- bot/cogs/codeblock/cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 2576be966..63b971b84 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -55,7 +55,7 @@ class CodeBlockCog(Cog, name="Code Block"): self.bot = bot # Stores allowed channels plus epoch times since the last instructional messages sent. - self.channel_cooldowns = {channel: 0.0 for channel in constants.CodeBlock.cooldown_channels} + self.channel_cooldowns = dict.fromkeys(constants.CodeBlock.cooldown_channels, 0.0) # Maps users' messages to the messages the bot sent with instructions. self.codeblock_message_ids = {} -- cgit v1.2.3 From 621043a7ebc7574455394959a690913064100101 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 27 Jun 2020 16:23:34 -0700 Subject: Code block: clarify get_instructions's docstring It wasn't clear that it also parses the message content. --- bot/cogs/codeblock/instructions.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 4ea5ca094..c25b2af5d 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -147,7 +147,11 @@ def _get_no_lang_message(content: str) -> Optional[str]: def get_instructions(content: str) -> Optional[str]: - """Return code block formatting instructions for `content` or None if nothing's wrong.""" + """ + Parse `content` and return code block formatting instructions if something is wrong. + + Return None if `content` lacks code block formatting issues. + """ log.trace("Getting formatting instructions.") blocks = parsing.find_code_blocks(content) -- cgit v1.2.3 From 201895180ffbe88c01e4dbc40dd9cd6c043e2be7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 27 Jun 2020 16:28:19 -0700 Subject: HelpChannels: fix is_in_category call It was still using it like it was a method of the class rather than calling it from the channel utils module. --- bot/cogs/help_channels.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 927d05da8..f0945b83c 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -715,7 +715,7 @@ class HelpChannels(Scheduler, commands.Cog): The new time for the dormant task is configured with `HelpChannels.deleted_idle_minutes`. """ - if not self.is_in_category(msg.channel, constants.Categories.help_in_use): + if not channel_utils.is_in_category(msg.channel, constants.Categories.help_in_use): return if not await self.is_empty(msg.channel): -- cgit v1.2.3 From c7d466a36d5775eb0a373242b7e4214b4534ad20 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 27 Jun 2020 16:50:16 -0700 Subject: Code block: fix BadLanguage creation Forgot to change the kwarg names when the attributes were renamed. --- bot/cogs/codeblock/parsing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 31cbd09b9..112ca12b6 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -148,6 +148,6 @@ def parse_bad_language(content: str) -> Optional[BadLanguage]: return BadLanguage( language=match["lang"], - leading_spaces=match["spaces"] is not None, - terminal_newline=match["newline"] is not None, + has_leading_spaces=match["spaces"] is not None, + has_terminal_newline=match["newline"] is not None, ) -- cgit v1.2.3 From de592dc5eb22d061c9b988844e8c7d695a37fa58 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 27 Jun 2020 20:05:20 -0700 Subject: Code block: support IPython REPL detection --- LICENSE-THIRD-PARTY | 36 ++++++++++++++++++++++++++++++++++++ bot/cogs/codeblock/parsing.py | 23 +++++++++++++++++------ 2 files changed, 53 insertions(+), 6 deletions(-) create mode 100644 LICENSE-THIRD-PARTY diff --git a/LICENSE-THIRD-PARTY b/LICENSE-THIRD-PARTY new file mode 100644 index 000000000..3349d7c05 --- /dev/null +++ b/LICENSE-THIRD-PARTY @@ -0,0 +1,36 @@ +BSD 3-Clause License + +Applies to: +- _RE_PYTHON_REPL and portions of _RE_IPYTHON_REPL in bot/cogs/codeblock/parsing.py + +- Copyright (c) 2008-Present, IPython Development Team +- Copyright (c) 2001-2007, Fernando Perez +- Copyright (c) 2001, Janko Hauser +- Copyright (c) 2001, Nathaniel Gray + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 112ca12b6..757acdd0f 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -26,6 +26,9 @@ _TICKS = { "\u3003", # VERTICAL KANA REPEAT MARK UPPER HALF } +_RE_PYTHON_REPL = re.compile(r"^(>>>|\.\.\.)( |$)") +_RE_IPYTHON_REPL = re.compile(r"^((In|Out) \[\d+\]: |\s*\.{3,}: ?)") + _RE_CODE_BLOCK = re.compile( fr""" (?P @@ -118,19 +121,27 @@ def is_python_code(content: str) -> bool: def is_repl_code(content: str, threshold: int = 3) -> bool: - """Return True if `content` has at least `threshold` number of Python REPL-like lines.""" - log.trace(f"Checking if content is Python REPL code using a threshold of {threshold}.") + """Return True if `content` has at least `threshold` number of (I)Python REPL-like lines.""" + log.trace(f"Checking if content is (I)Python REPL code using a threshold of {threshold}.") repl_lines = 0 + patterns = (_RE_PYTHON_REPL, _RE_IPYTHON_REPL) + for line in content.splitlines(): - if line.startswith(">>> ") or line.startswith("... "): - repl_lines += 1 + # Check the line against all patterns. + for pattern in patterns: + if pattern.match(line): + repl_lines += 1 + + # Once a pattern is matched, only use that pattern for the remaining lines. + patterns = (pattern,) + break if repl_lines == threshold: - log.trace("Content is Python REPL code.") + log.trace("Content is (I)Python REPL code.") return True - log.trace("Content is not Python REPL code.") + log.trace("Content is not (I)Python REPL code.") return False -- cgit v1.2.3 From d41f3568542528580e0fe0ff5b43bfbae2dde584 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 28 Jun 2020 18:15:14 -0700 Subject: Code block: re-add indentation fixing function It's still useful to fix indentation to ensure AST is correctly parsed. This function deals with the relatively common case of a the leading spaces of the first line being left out when copy-pasting. --- bot/cogs/codeblock/parsing.py | 49 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 757acdd0f..5b4cb9fdd 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -162,3 +162,52 @@ def parse_bad_language(content: str) -> Optional[BadLanguage]: has_leading_spaces=match["spaces"] is not None, has_terminal_newline=match["newline"] is not None, ) + + +def _get_leading_spaces(content: str) -> int: + """Return the number of spaces at the start of the first line in `content`.""" + current = content[0] + leading_spaces = 0 + + while current == " ": + leading_spaces += 1 + current = content[leading_spaces] + + return leading_spaces + + +def _fix_indentation(content: str) -> str: + """ + Attempt to fix badly indented code in `content`. + + In most cases, this works like textwrap.dedent. However, if the first line ends with a colon, + all subsequent lines are re-indented to only be one level deep relative to the first line. + The intent is to fix cases where the leading spaces of the first line of code were accidentally + not copied, which makes the first line appear not indented. + + This is fairly naïve and inaccurate. Therefore, it may break some code that was otherwise valid. + It's meant to catch really common cases, so that's acceptable. Its flaws are: + + - It assumes that if the first line ends with a colon, it is the start of an indented block + - It uses 4 spaces as the indentation, regardless of what the rest of the code uses + """ + lines = content.splitlines(keepends=True) + + # Dedent the first line + first_indent = _get_leading_spaces(content) + first_line = lines[0][first_indent:] + + second_indent = _get_leading_spaces(lines[1]) + + # If the first line ends with a colon, all successive lines need to be indented one + # additional level (assumes an indent width of 4). + if first_line.rstrip().endswith(":"): + second_indent -= 4 + + # All lines must be dedented at least by the same amount as the first line. + first_indent = max(first_indent, second_indent) + + # Dedent the rest of the lines and join them together with the first line. + content = first_line + "".join(line[first_indent:] for line in lines[1:]) + + return content -- cgit v1.2.3 From d8b8c518db9fd8bc0d0eb43afe38845c710af9a2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 28 Jun 2020 18:21:54 -0700 Subject: Code block: dedent code before validating it If it's indented too far, the AST parser will fail. --- bot/cogs/codeblock/instructions.py | 4 ++-- bot/cogs/codeblock/parsing.py | 17 +++++++++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index c25b2af5d..56b85a34f 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -70,7 +70,7 @@ def _get_no_ticks_message(content: str) -> Optional[str]: """If `content` is Python/REPL code, return instructions on using code blocks.""" log.trace("Creating instructions for a missing code block.") - if parsing.is_repl_code(content) or parsing.is_python_code(content): + if parsing.is_python_code(content): example_blocks = _get_example("python") return ( "It looks like you're trying to paste code into this channel.\n\n" @@ -132,7 +132,7 @@ def _get_no_lang_message(content: str) -> Optional[str]: """ log.trace("Creating instructions for a missing language.") - if parsing.is_repl_code(content) or parsing.is_python_code(content): + if parsing.is_python_code(content): example_blocks = _get_example("python") # Note that _get_bad_ticks_message expects the first line to have two newlines. diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 5b4cb9fdd..ea007b6f1 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -3,6 +3,7 @@ import ast import logging import re +import textwrap from typing import NamedTuple, Optional, Sequence from bot import constants @@ -98,7 +99,7 @@ def find_code_blocks(message: str) -> Optional[Sequence[CodeBlock]]: return code_blocks -def is_python_code(content: str) -> bool: +def _is_python_code(content: str) -> bool: """Return True if `content` is valid Python consisting of more than just expressions.""" log.trace("Checking if content is Python code.") try: @@ -120,7 +121,7 @@ def is_python_code(content: str) -> bool: return False -def is_repl_code(content: str, threshold: int = 3) -> bool: +def _is_repl_code(content: str, threshold: int = 3) -> bool: """Return True if `content` has at least `threshold` number of (I)Python REPL-like lines.""" log.trace(f"Checking if content is (I)Python REPL code using a threshold of {threshold}.") @@ -145,6 +146,18 @@ def is_repl_code(content: str, threshold: int = 3) -> bool: return False +def is_python_code(content: str) -> bool: + """Return True if `content` is valid Python code or (I)Python REPL output.""" + dedented = textwrap.dedent(content) + + # Parse AST twice in case _fix_indentation ends up breaking code due to its inaccuracies. + return ( + _is_python_code(dedented) + or _is_repl_code(dedented) + or _is_python_code(_fix_indentation(content)) + ) + + def parse_bad_language(content: str) -> Optional[BadLanguage]: """ Return information about a poorly formatted Python language in code block `content`. -- cgit v1.2.3 From 4fd2ff500cd889c1086334e82f695857689ae328 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 29 Jun 2020 19:11:52 -0700 Subject: Scheduler: add details to class docstring --- bot/utils/scheduling.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index cf2a1f110..fc453f19e 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -8,7 +8,17 @@ from functools import partial class Scheduler: - """Task scheduler.""" + """ + Schedule the execution of coroutines and keep track of them. + + Coroutines can be scheduled immediately with `schedule` or in the future with `schedule_at` + or `schedule_later`. A unique ID is required to be given in order to keep track of the + resulting Tasks. Any scheduled task can be cancelled prematurely using `cancel` by providing + the same ID used to schedule it. The `in` operator is supported for checking if a task with a + given ID is currently scheduled. + + Any exception raised in a scheduled task is logged when the task is done. + """ def __init__(self, name: str): self.name = name -- cgit v1.2.3 From c641f7fbbebd4c4c18539c32eb3d3907c8e71dee Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 29 Jun 2020 19:15:43 -0700 Subject: Scheduler: explain the name param in the docstring --- bot/utils/scheduling.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index fc453f19e..0987c5de8 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -11,6 +11,10 @@ class Scheduler: """ Schedule the execution of coroutines and keep track of them. + When instantiating a Scheduler, a name must be provided. This name is used to distinguish the + instance's log messages from other instances. Using the name of the class or module containing + the instance is suggested. + Coroutines can be scheduled immediately with `schedule` or in the future with `schedule_at` or `schedule_later`. A unique ID is required to be given in order to keep track of the resulting Tasks. Any scheduled task can be cancelled prematurely using `cancel` by providing -- cgit v1.2.3 From be4a61fb70c485262d36ca2aabf992f3118abcff Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 30 Jun 2020 23:09:00 +0200 Subject: Incidents: revert latest 2 commits Decision was made to use embeds to archive incidents instead of webhooking the raw message. As such, we're reverting the branch to a state from which the adjustments will be easier to make. Reverted commits: * a8d179d9b04f54b20c5e870bcfa85c78c42c8dca * 6fa8caed037b247a7c194f58a4635de7dae21fd2 --- bot/cogs/moderation/incidents.py | 33 +++--------------- tests/bot/cogs/moderation/test_incidents.py | 52 ++++------------------------- 2 files changed, 10 insertions(+), 75 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 72cc4b26c..040f2c0c8 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -41,30 +41,6 @@ ALLOWED_ROLES: t.Set[int] = {Roles.moderators, Roles.admins, Roles.owners} ALL_SIGNALS: t.Set[str] = {signal.value for signal in Signal} -def make_username(reported_by: discord.Member, actioned_by: discord.Member, max_length: int = 80) -> str: - """ - Create a webhook-friendly username from the names of `reported_by` and `actioned_by`. - - If the resulting username length exceeds `max_length`, it will be capped at `max_length - 3` - and have 3 dots appended to the end. The default value is 80, which corresponds to the limit - Discord imposes on webhook username length. - - If the value of `max_length` is < 3, ValueError is raised. - """ - if max_length < 3: - raise ValueError(f"Maximum length cannot be less than 3: {max_length=}") - - username = f"{reported_by.name} | {actioned_by.name}" - log.trace(f"Generated webhook username: {username} (length: {len(username)})") - - if len(username) > max_length: - stop = max_length - 3 - username = f"{username[:stop]}..." - log.trace(f"Username capped at {max_length=}: {username}") - - return username - - def is_incident(message: discord.Message) -> bool: """True if `message` qualifies as an incident, False otherwise.""" conditions = ( @@ -172,14 +148,13 @@ class Incidents(Cog): log.debug("Crawl task finished!") - async def archive(self, incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> bool: + async def archive(self, incident: discord.Message, outcome: Signal) -> bool: """ Relay `incident` to the #incidents-archive channel. The following pieces of information are relayed: * Incident message content (clean, pingless) - * Incident author name (as webhook username) - * Name of user who actioned the incident (appended to webhook username) + * Incident author name (as webhook author) * Incident author avatar (as webhook avatar) * Resolution signal (`outcome`) @@ -195,7 +170,7 @@ class Incidents(Cog): # Now relay the incident message: discord.Message = await webhook.send( content=incident.clean_content, # Clean content will prevent mentions from pinging - username=sub_clyde(make_username(incident.author, actioned_by)), + username=sub_clyde(incident.author.name), avatar_url=incident.author.avatar_url, wait=True, # This makes the method return the sent Message object ) @@ -260,7 +235,7 @@ class Incidents(Cog): log.debug("Reaction was valid, but no action is currently defined for it") return - relay_successful = await self.archive(incident, signal, actioned_by=member) + relay_successful = await self.archive(incident, signal) if not relay_successful: log.trace("Original message will not be deleted as we failed to relay it to the archive") return diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index a811868e5..2fc9180cf 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -68,35 +68,6 @@ mock_404 = discord.NotFound( ) -class TestMakeUsername(unittest.TestCase): - """Collection of tests for the `make_username` helper function.""" - - def test_make_username_raises(self): - """Raises `ValueError` on `max_length` < 3.""" - with self.assertRaises(ValueError): - incidents.make_username(MockMember(), MockMember(), max_length=2) - - def test_make_username_never_exceed_limit(self): - """ - The return string length is always less than or equal to `max_length`. - - For this test we pass `max_length=10` for convenience. The name of the first - user (`reported_by`) is always 1 character in length, but we generate names - for the `actioned_by` user starting at length 1 and up to length 20. - - Finally, we assert that the output length never exceeded 10 in total. - """ - user_a = MockMember(name="A") - - max_length = 10 - test_cases = (MockMember(name="B" * n) for n in range(1, 20)) - - for user_b in test_cases: - with self.subTest(user_a=user_a, user_b=user_b, max_length=max_length): - generated_username = incidents.make_username(user_a, user_b, max_length) - self.assertLessEqual(len(generated_username), max_length) - - @patch("bot.constants.Channels.incidents", 123) class TestIsIncident(unittest.TestCase): """ @@ -307,9 +278,7 @@ class TestArchive(TestIncidents): propagate out of the method, which is just as important. """ self.cog_instance.bot.fetch_webhook = AsyncMock(side_effect=mock_404) - - result = await self.cog_instance.archive(incident=MockMessage(), outcome=MagicMock(), actioned_by=MockMember()) - self.assertFalse(result) + self.assertFalse(await self.cog_instance.archive(incident=MockMessage(), outcome=MagicMock())) async def test_archive_relays_incident(self): """ @@ -334,18 +303,12 @@ class TestArchive(TestIncidents): author=MockUser(name="author_name", avatar_url="author_avatar"), id=123, ) - - with patch("bot.cogs.moderation.incidents.make_username", MagicMock(return_value="generated_username")): - archive_return = await self.cog_instance.archive( - incident=incident, - outcome=MagicMock(value="A"), - actioned_by=MockMember(name="moderator"), - ) + archive_return = await self.cog_instance.archive(incident, outcome=MagicMock(value="A")) # Check that the webhook was dispatched correctly webhook.send.assert_called_once_with( content="pingless message", - username="generated_username", + username="author_name", avatar_url="author_avatar", wait=True, ) @@ -362,8 +325,7 @@ class TestArchive(TestIncidents): Discord will reject any webhook with "clyde" in the username field, as it impersonates the official Clyde bot. Since we do not control what the username will be (the incident - author name, and actioning moderator names are used), we must ensure the name is cleansed, - otherwise the relay may fail. + author name is used), we must ensure the name is cleansed, otherwise the relay may fail. This test assumes the username is passed as a kwarg. If this test fails, please review whether the passed argument is being retrieved correctly. @@ -371,11 +333,9 @@ class TestArchive(TestIncidents): webhook = MockAsyncWebhook() self.cog_instance.bot.fetch_webhook = AsyncMock(return_value=webhook) - # The `make_username` helper will return a string with "clyde" in it - with patch("bot.cogs.moderation.incidents.make_username", MagicMock(return_value="clyde the great")): - await self.cog_instance.archive(MockMessage(), MagicMock(incidents.Signal), MockMember()) + message_from_clyde = MockMessage(author=MockUser(name="clyde the great")) + await self.cog_instance.archive(message_from_clyde, MagicMock(incidents.Signal)) - # Assert that the "clyde" was never passed to `send` self.assertNotIn("clyde", webhook.send.call_args.kwargs["username"]) -- cgit v1.2.3 From 968251660768297383401576902a71f8ac9edada Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 30 Jun 2020 23:15:02 +0200 Subject: Incidents: pass `actioned_by` to `archive` This is an important piece of information that shall be relayed. --- bot/cogs/moderation/incidents.py | 4 ++-- tests/bot/cogs/moderation/test_incidents.py | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 040f2c0c8..580a258fe 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -148,7 +148,7 @@ class Incidents(Cog): log.debug("Crawl task finished!") - async def archive(self, incident: discord.Message, outcome: Signal) -> bool: + async def archive(self, incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> bool: """ Relay `incident` to the #incidents-archive channel. @@ -235,7 +235,7 @@ class Incidents(Cog): log.debug("Reaction was valid, but no action is currently defined for it") return - relay_successful = await self.archive(incident, signal) + relay_successful = await self.archive(incident, signal, actioned_by=member) if not relay_successful: log.trace("Original message will not be deleted as we failed to relay it to the archive") return diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 2fc9180cf..c2e32fe6b 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -278,7 +278,9 @@ class TestArchive(TestIncidents): propagate out of the method, which is just as important. """ self.cog_instance.bot.fetch_webhook = AsyncMock(side_effect=mock_404) - self.assertFalse(await self.cog_instance.archive(incident=MockMessage(), outcome=MagicMock())) + self.assertFalse( + await self.cog_instance.archive(incident=MockMessage(), outcome=MagicMock(), actioned_by=MockMember()) + ) async def test_archive_relays_incident(self): """ @@ -303,7 +305,7 @@ class TestArchive(TestIncidents): author=MockUser(name="author_name", avatar_url="author_avatar"), id=123, ) - archive_return = await self.cog_instance.archive(incident, outcome=MagicMock(value="A")) + archive_return = await self.cog_instance.archive(incident, MagicMock(value="A"), MockMember()) # Check that the webhook was dispatched correctly webhook.send.assert_called_once_with( @@ -334,7 +336,7 @@ class TestArchive(TestIncidents): self.cog_instance.bot.fetch_webhook = AsyncMock(return_value=webhook) message_from_clyde = MockMessage(author=MockUser(name="clyde the great")) - await self.cog_instance.archive(message_from_clyde, MagicMock(incidents.Signal)) + await self.cog_instance.archive(message_from_clyde, MagicMock(incidents.Signal), MockMember()) self.assertNotIn("clyde", webhook.send.call_args.kwargs["username"]) -- cgit v1.2.3 From 7e2450bb650312ee79ac159621c4376c784a8398 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Wed, 1 Jul 2020 00:24:00 +0000 Subject: Add base Slowmode cog --- bot/__main__.py | 1 + bot/cogs/slowmode.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 bot/cogs/slowmode.py diff --git a/bot/__main__.py b/bot/__main__.py index 4e0d4a111..bbd9c9144 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -62,6 +62,7 @@ bot.load_extension("bot.cogs.off_topic_names") bot.load_extension("bot.cogs.reddit") bot.load_extension("bot.cogs.reminders") bot.load_extension("bot.cogs.site") +bot.load_extension("bot.cogs.slowmode") bot.load_extension("bot.cogs.snekbox") bot.load_extension("bot.cogs.stats") bot.load_extension("bot.cogs.sync") diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py new file mode 100644 index 000000000..96c069ab8 --- /dev/null +++ b/bot/cogs/slowmode.py @@ -0,0 +1,14 @@ +from discord.ext.commands import Cog + +from bot.bot import Bot + + +class Slowmode(Cog): + + def __init__(self, bot: Bot) -> None: + self.bot = bot + + +def setup(bot: Bot) -> None: + """Load the Slowmode cog.""" + bot.add_cog(Slowmode(bot)) -- cgit v1.2.3 From 3ec5a69f8e1709aca55da3abc24cb2e632ae1ddb Mon Sep 17 00:00:00 2001 From: Den4200 Date: Wed, 1 Jul 2020 00:28:05 +0000 Subject: Create boilerplate code for the commands --- bot/cogs/slowmode.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index 96c069ab8..9140f3e8f 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -1,6 +1,9 @@ -from discord.ext.commands import Cog +from discord import TextChannel +from discord.ext.commands import Cog, Context, group from bot.bot import Bot +from bot.constants import MODERATION_ROLES +from bot.decorators import with_role class Slowmode(Cog): @@ -8,6 +11,20 @@ class Slowmode(Cog): def __init__(self, bot: Bot) -> None: self.bot = bot + @group(name='slowmode', aliases=['sm'], invoke_without_command=True) + async def slowmode_group(self, ctx: Context) -> None: + """Get and set the slowmode delay for a given text channel.""" + await ctx.send_help(ctx.command) + + @slowmode_group.command(name='get', aliases=['g']) + async def get_slowmode(self, ctx: Context, channel: TextChannel) -> None: + """Get the slowmode delay for a given text channel.""" + + @slowmode_group.command(name='set', aliases=['s']) + @with_role(*MODERATION_ROLES) + async def set_slowmode(self, ctx: Context, channel: TextChannel, seconds: int) -> None: + """Set the slowmode delay for a given text channel.""" + def setup(bot: Bot) -> None: """Load the Slowmode cog.""" -- cgit v1.2.3 From 38bd45d97127504ac38a098d86ebc0a83723110a Mon Sep 17 00:00:00 2001 From: Den4200 Date: Wed, 1 Jul 2020 00:30:00 +0000 Subject: Implement the get_slowmode function --- bot/cogs/slowmode.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index 9140f3e8f..d4226acec 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -19,6 +19,8 @@ class Slowmode(Cog): @slowmode_group.command(name='get', aliases=['g']) async def get_slowmode(self, ctx: Context, channel: TextChannel) -> None: """Get the slowmode delay for a given text channel.""" + slowmode_delay = channel.slowmode_delay + await ctx.send(f'The slowmode delay for {channel.mention} is {slowmode_delay} seconds.') @slowmode_group.command(name='set', aliases=['s']) @with_role(*MODERATION_ROLES) -- cgit v1.2.3 From 2172154c8cfe77b495e3c71716c3df339bf573b1 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Wed, 1 Jul 2020 00:31:12 +0000 Subject: Implement the set_slowmode function --- bot/cogs/slowmode.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index d4226acec..bab6eccd0 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -2,7 +2,7 @@ from discord import TextChannel from discord.ext.commands import Cog, Context, group from bot.bot import Bot -from bot.constants import MODERATION_ROLES +from bot.constants import Emojis, MODERATION_ROLES from bot.decorators import with_role @@ -26,6 +26,10 @@ class Slowmode(Cog): @with_role(*MODERATION_ROLES) async def set_slowmode(self, ctx: Context, channel: TextChannel, seconds: int) -> None: """Set the slowmode delay for a given text channel.""" + await channel.edit(slowmode_delay=seconds) + await ctx.send( + f'{Emojis.check_mark} The slowmode delay for {channel.mention} is now {seconds} seconds.' + ) def setup(bot: Bot) -> None: -- cgit v1.2.3 From 7af6b6f52e1dff19e04bb106f27f0f2409788e10 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Wed, 1 Jul 2020 00:37:38 +0000 Subject: Ensure slowmode delay is between 0 and 21600 seconds before setting it --- bot/cogs/slowmode.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index bab6eccd0..4a10d3fac 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -26,10 +26,16 @@ class Slowmode(Cog): @with_role(*MODERATION_ROLES) async def set_slowmode(self, ctx: Context, channel: TextChannel, seconds: int) -> None: """Set the slowmode delay for a given text channel.""" - await channel.edit(slowmode_delay=seconds) - await ctx.send( - f'{Emojis.check_mark} The slowmode delay for {channel.mention} is now {seconds} seconds.' - ) + if 0 <= seconds <= 21600: + await channel.edit(slowmode_delay=seconds) + await ctx.send( + f'{Emojis.check_mark} The slowmode delay for {channel.mention} is now {seconds} seconds.' + ) + + else: + await ctx.send( + f'{Emojis.cross_mark} The slowmode delay must be between 0 and 21600 seconds.' + ) def setup(bot: Bot) -> None: -- cgit v1.2.3 From 743f729d8ec039ef616a24eb291c8af5bec84c26 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Wed, 1 Jul 2020 00:57:47 +0000 Subject: Add reset_slowmode function --- bot/cogs/slowmode.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index 4a10d3fac..a4eb428e9 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -37,6 +37,15 @@ class Slowmode(Cog): f'{Emojis.cross_mark} The slowmode delay must be between 0 and 21600 seconds.' ) + @slowmode_group.command(name='reset', aliases=['r']) + @with_role(*MODERATION_ROLES) + async def reset_slowmode(self, ctx: Context, channel: TextChannel) -> None: + """Reset the slowmode delay for a given text channel to 0 seconds.""" + await channel.edit(slowmode_delay=0) + await ctx.send( + f'{Emojis.check_mark} The slowmode delay for {channel.mention} has been reset to 0 seconds.' + ) + def setup(bot: Bot) -> None: """Load the Slowmode cog.""" -- cgit v1.2.3 From 7b90754f74170d4a8db0008a9c08a690c01a7618 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Wed, 1 Jul 2020 01:17:44 +0000 Subject: Create docstring for Slowmode cog --- bot/cogs/slowmode.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index a4eb428e9..a650ac395 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -7,6 +7,7 @@ from bot.decorators import with_role class Slowmode(Cog): + """Commands for getting and setting slowmode delays of text channels.""" def __init__(self, bot: Bot) -> None: self.bot = bot -- cgit v1.2.3 From 18dace4da6868f0a8aa6c64728994c68695fed95 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Wed, 1 Jul 2020 01:36:25 +0000 Subject: Add some logging for the Slowmode cog --- bot/cogs/slowmode.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index a650ac395..7bbd61623 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -1,3 +1,5 @@ +import logging + from discord import TextChannel from discord.ext.commands import Cog, Context, group @@ -5,6 +7,8 @@ from bot.bot import Bot from bot.constants import Emojis, MODERATION_ROLES from bot.decorators import with_role +log = logging.getLogger(__name__) + class Slowmode(Cog): """Commands for getting and setting slowmode delays of text channels.""" @@ -33,10 +37,16 @@ class Slowmode(Cog): f'{Emojis.check_mark} The slowmode delay for {channel.mention} is now {seconds} seconds.' ) + log.info(f'{ctx.author} set the slowmode delay for #{channel} to {seconds} seconds.') + else: await ctx.send( f'{Emojis.cross_mark} The slowmode delay must be between 0 and 21600 seconds.' ) + log.info( + f'{ctx.author} tried to set the slowmode delay of #{channel} to {seconds} seconds, ' + 'which is not between 0 and 21600 seconds.' + ) @slowmode_group.command(name='reset', aliases=['r']) @with_role(*MODERATION_ROLES) @@ -46,6 +56,7 @@ class Slowmode(Cog): await ctx.send( f'{Emojis.check_mark} The slowmode delay for {channel.mention} has been reset to 0 seconds.' ) + log.info(f'{ctx.author} reset the slowmode delay for #{channel} to 0 seconds.') def setup(bot: Bot) -> None: -- cgit v1.2.3 From da93dc5d2eb06eae05c6180de2bd66f3fca90c1d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 30 Jun 2020 18:41:44 -0700 Subject: Scheduler: more verbose logging in _await_later Showing the task ID in the logs makes them distinguishable from logs for other tasks. The coroutine state is logged because it may come in handy while debugging; the coroutine inspection check hasn't been proven yet in production. --- bot/utils/scheduling.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 0987c5de8..9fc519393 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -62,13 +62,13 @@ class Scheduler: """ delay = (time - datetime.utcnow()).total_seconds() if delay > 0: - coroutine = self._await_later(delay, coroutine) + coroutine = self._await_later(delay, task_id, coroutine) self.schedule(task_id, coroutine) def schedule_later(self, delay: t.Union[int, float], task_id: t.Hashable, coroutine: t.Coroutine) -> None: """Schedule `coroutine` to be executed after the given `delay` number of seconds.""" - self.schedule(task_id, self._await_later(delay, coroutine)) + self.schedule(task_id, self._await_later(delay, task_id, coroutine)) def cancel(self, task_id: t.Hashable) -> None: """Unschedule the task identified by `task_id`. Log a warning if the task doesn't exist.""" @@ -90,23 +90,26 @@ class Scheduler: for task_id in self._scheduled_tasks.copy(): self.cancel(task_id) - async def _await_later(self, delay: t.Union[int, float], coroutine: t.Coroutine) -> None: + async def _await_later(self, delay: t.Union[int, float], task_id: t.Hashable, coroutine: t.Coroutine) -> None: """Await `coroutine` after the given `delay` number of seconds.""" try: - self._log.trace(f"Waiting {delay} seconds before awaiting the coroutine.") + self._log.trace(f"Waiting {delay} seconds before awaiting coroutine for #{task_id}.") await asyncio.sleep(delay) # Use asyncio.shield to prevent the coroutine from cancelling itself. - self._log.trace("Done waiting; now awaiting the coroutine.") + self._log.trace(f"Done waiting for #{task_id}; now awaiting the coroutine.") await asyncio.shield(coroutine) finally: # Close it to prevent unawaited coroutine warnings, # which would happen if the task was cancelled during the sleep. # Only close it if it's not been awaited yet. This check is important because the # coroutine may cancel this task, which would also trigger the finally block. - if inspect.getcoroutinestate(coroutine) == "CORO_CREATED": - self._log.trace("Explicitly closing the coroutine.") + state = inspect.getcoroutinestate(coroutine) + if state == "CORO_CREATED": + self._log.debug(f"Explicitly closing the coroutine for #{task_id}.") coroutine.close() + else: + self._log.debug(f"Finally block reached for #{task_id}; {state=}") def _task_done_callback(self, task_id: t.Hashable, done_task: asyncio.Task) -> None: """ -- cgit v1.2.3 From dd74105d4a4433bb9e9e6fa57960a4956c0f1231 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 30 Jun 2020 23:42:32 +0200 Subject: Incidents: implement `make_embed` helper & tests See `make_embed` docstring for further information. The tests are fairly loose and should be easily adjustable in the future should changes be made. --- bot/cogs/moderation/incidents.py | 32 ++++++++++++++++++++++++++++- tests/bot/cogs/moderation/test_incidents.py | 26 +++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 580a258fe..ca591fc6e 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -1,13 +1,14 @@ import asyncio import logging import typing as t +from datetime import datetime from enum import Enum import discord from discord.ext.commands import Cog from bot.bot import Bot -from bot.constants import Channels, Emojis, Roles, Webhooks +from bot.constants import Channels, Colours, Emojis, Roles, Webhooks from bot.utils.messages import sub_clyde log = logging.getLogger(__name__) @@ -41,6 +42,35 @@ ALLOWED_ROLES: t.Set[int] = {Roles.moderators, Roles.admins, Roles.owners} ALL_SIGNALS: t.Set[str] = {signal.value for signal in Signal} +def make_embed(incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> discord.Embed: + """ + Create an embed representation of `incident` for the #incidents-archive channel. + + The name & discriminator of `actioned_by` and `outcome` will be presented in the + embed footer. Additionally, the embed is coloured based on `outcome`. + + The author of `incident` is not shown in the embed. It is assumed that this piece + of information will be relayed in other ways, e.g. webhook username. + + As mentions in embeds do not ping, we do not need to use `incident.clean_content`. + """ + if outcome is Signal.ACTIONED: + colour = Colours.soft_green + footer = f"Actioned by {actioned_by}" + else: + colour = Colours.soft_red + footer = f"Rejected by {actioned_by}" + + embed = discord.Embed( + description=incident.content, + timestamp=datetime.utcnow(), + colour=colour, + ) + embed.set_footer(text=footer, icon_url=actioned_by.avatar_url) + + return embed + + def is_incident(message: discord.Message) -> bool: """True if `message` qualifies as an incident, False otherwise.""" conditions = ( diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index c2e32fe6b..4731a786d 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -9,6 +9,7 @@ import aiohttp import discord from bot.cogs.moderation import Incidents, incidents +from bot.constants import Colours from tests.helpers import ( MockAsyncWebhook, MockBot, @@ -68,6 +69,31 @@ mock_404 = discord.NotFound( ) +class TestMakeEmbed(unittest.TestCase): + """Collection of tests for the `make_embed` helper function.""" + + def test_make_embed_actioned(self): + """Embed is coloured green and footer contains 'Actioned' when `outcome=Signal.ACTIONED`.""" + embed = incidents.make_embed(MockMessage(), incidents.Signal.ACTIONED, MockMember()) + + self.assertEqual(embed.colour.value, Colours.soft_green) + self.assertIn("Actioned", embed.footer.text) + + def test_make_embed_not_actioned(self): + """Embed is coloured red and footer contains 'Rejected' when `outcome=Signal.NOT_ACTIONED`.""" + embed = incidents.make_embed(MockMessage(), incidents.Signal.NOT_ACTIONED, MockMember()) + + self.assertEqual(embed.colour.value, Colours.soft_red) + self.assertIn("Rejected", embed.footer.text) + + def test_make_embed_content(self): + """Incident content appears as embed description.""" + incident = MockMessage(content="this is an incident") + embed = incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember()) + + self.assertEqual(incident.content, embed.description) + + @patch("bot.constants.Channels.incidents", 123) class TestIsIncident(unittest.TestCase): """ -- cgit v1.2.3 From 744aed585162cb0547e61a538734f116459ab510 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 1 Jul 2020 16:52:58 +0200 Subject: Incidents: relay incidents as embeds rather than raw content This applies the previously defined `make_embed` function. As the `archive` function is now simpler, I decided to reduce the amount of whitespace ~ it's a lot more compact now. Tests are adjusted as appropriate. --- bot/cogs/moderation/incidents.py | 24 ++++++++-------------- tests/bot/cogs/moderation/test_incidents.py | 32 ++++++++++------------------- 2 files changed, 19 insertions(+), 37 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index ca591fc6e..3a1a3d84e 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -180,38 +180,30 @@ class Incidents(Cog): async def archive(self, incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> bool: """ - Relay `incident` to the #incidents-archive channel. + Relay an embed representation of `incident` to the #incidents-archive channel. The following pieces of information are relayed: - * Incident message content (clean, pingless) + * Incident message content (as embed description) * Incident author name (as webhook author) * Incident author avatar (as webhook avatar) - * Resolution signal (`outcome`) + * Resolution signal `outcome` (as embed colour & footer) + * Moderator `actioned_by` (name & discriminator shown in footer) Return True if the relay finishes successfully. If anything goes wrong, meaning not all information was relayed, return False. This signals that the original message is not safe to be deleted, as we will lose some information. """ - log.debug(f"Archiving incident: {incident.id} with outcome: {outcome}") + log.debug(f"Archiving incident: {incident.id} (outcome: {outcome}, actioned by: {actioned_by})") try: - # First we try to grab the webhook - webhook: discord.Webhook = await self.bot.fetch_webhook(Webhooks.incidents_archive) - - # Now relay the incident - message: discord.Message = await webhook.send( - content=incident.clean_content, # Clean content will prevent mentions from pinging + webhook = await self.bot.fetch_webhook(Webhooks.incidents_archive) + await webhook.send( + embed=make_embed(incident, outcome, actioned_by), username=sub_clyde(incident.author.name), avatar_url=incident.author.avatar_url, - wait=True, # This makes the method return the sent Message object ) - - # Finally add the `outcome` emoji - await message.add_reaction(outcome.value) - except Exception: log.exception(f"Failed to archive incident {incident.id} to #incidents-archive") return False - else: log.trace("Message archived successfully!") return True diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 4731a786d..70dfe6b5f 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -312,39 +312,29 @@ class TestArchive(TestIncidents): """ If webhook is found, method relays `incident` properly. - This test will assert the following: - * The fetched webhook's `send` method is fed the correct arguments - * The message returned by `send` will have `outcome` reaction added - * Finally, the `archive` method returns True - - Assertions are made specifically in this order. + This test will assert that the fetched webhook's `send` method is fed the correct arguments, + and that the `archive` method returns True. """ - webhook_message = MockMessage() # The message that will be returned by the webhook's `send` method - webhook = MockAsyncWebhook(send=AsyncMock(return_value=webhook_message)) - + webhook = MockAsyncWebhook() self.cog_instance.bot.fetch_webhook = AsyncMock(return_value=webhook) # Patch in our webhook - # Now we'll pas our own `incident` to `archive` and capture the return value + # Define our own `incident` for archivation incident = MockMessage( - clean_content="pingless message", - content="pingful message", + content="this is an incident", author=MockUser(name="author_name", avatar_url="author_avatar"), id=123, ) - archive_return = await self.cog_instance.archive(incident, MagicMock(value="A"), MockMember()) + built_embed = MagicMock(discord.Embed, id=123) # We patch `make_embed` to return this - # Check that the webhook was dispatched correctly + with patch("bot.cogs.moderation.incidents.make_embed", MagicMock(return_value=built_embed)): + archive_return = await self.cog_instance.archive(incident, MagicMock(value="A"), MockMember()) + + # Now we check that the webhook was given the correct args, and that `archive` returned True webhook.send.assert_called_once_with( - content="pingless message", + embed=built_embed, username="author_name", avatar_url="author_avatar", - wait=True, ) - - # Now check that the correct emoji was added to the relayed message - webhook_message.add_reaction.assert_called_once_with("A") - - # Finally check that the method returned True self.assertTrue(archive_return) async def test_archive_clyde_username(self): -- cgit v1.2.3 From bd041ef4363ad8750d619d97fb7e8f3a4c6ae757 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Wed, 1 Jul 2020 16:37:48 +0000 Subject: Create DurationDelta converter and humanize timedelta output for Slowmode cog. The DurationDelta converter will allow the Slowmode cog to use a formatted timestamp instead of an integer representing seconds. I created a new converter because the Duration converter returned a datetime.datetime object, instead of a time delta. Joe mentioned that I could just subtract the datetime.datetime object from datetime.utcnow(), but there is a small delay between conversion and when the function is actually executed. This caused something like `!slowmode set #python-general 5s` to set the slowmode delay to 4 seconds instead of 5. Now, with this new converter, the set command can be invoked using a formatted timestamp like so: `!slowmode set #python-general 4h23M19s`. This would set the slowmode delay in #python-general to 4 hours, 23 minutes, and 19 seconds. Of course that delay would be quite overkill for #python-general, but that's just for the sake of this example. --- bot/cogs/slowmode.py | 31 +++++++++++++++++++++---------- bot/converters.py | 22 ++++++++++++++++++---- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index 7bbd61623..898f4bf52 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -1,11 +1,15 @@ import logging +from datetime import datetime +from dateutil.relativedelta import relativedelta from discord import TextChannel from discord.ext.commands import Cog, Context, group from bot.bot import Bot from bot.constants import Emojis, MODERATION_ROLES +from bot.converters import DurationDelta from bot.decorators import with_role +from bot.utils import time log = logging.getLogger(__name__) @@ -24,28 +28,35 @@ class Slowmode(Cog): @slowmode_group.command(name='get', aliases=['g']) async def get_slowmode(self, ctx: Context, channel: TextChannel) -> None: """Get the slowmode delay for a given text channel.""" - slowmode_delay = channel.slowmode_delay - await ctx.send(f'The slowmode delay for {channel.mention} is {slowmode_delay} seconds.') + delay = relativedelta(seconds=channel.slowmode_delay) + await ctx.send(f'The slowmode delay for {channel.mention} is {time.humanize_delta(delay, precision=3)}.') @slowmode_group.command(name='set', aliases=['s']) @with_role(*MODERATION_ROLES) - async def set_slowmode(self, ctx: Context, channel: TextChannel, seconds: int) -> None: + async def set_slowmode(self, ctx: Context, channel: TextChannel, delay: DurationDelta) -> None: """Set the slowmode delay for a given text channel.""" - if 0 <= seconds <= 21600: - await channel.edit(slowmode_delay=seconds) + # Convert `dateutil.relativedelta.relativedelta` to `datetime.timedelta` + # Must do this to get the delta in a particular unit of time + utcnow = datetime.utcnow() + slowmode_delay = (utcnow + delay - utcnow).seconds + + humanized_delay = time.humanize_delta(delay, precision=3) + + if 0 <= slowmode_delay <= 21600: + await channel.edit(slowmode_delay=slowmode_delay) await ctx.send( - f'{Emojis.check_mark} The slowmode delay for {channel.mention} is now {seconds} seconds.' + f'{Emojis.check_mark} The slowmode delay for {channel.mention} is now {humanized_delay}.' ) - log.info(f'{ctx.author} set the slowmode delay for #{channel} to {seconds} seconds.') + log.info(f'{ctx.author} set the slowmode delay for #{channel} to {humanized_delay}.') else: await ctx.send( - f'{Emojis.cross_mark} The slowmode delay must be between 0 and 21600 seconds.' + f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.' ) log.info( - f'{ctx.author} tried to set the slowmode delay of #{channel} to {seconds} seconds, ' - 'which is not between 0 and 21600 seconds.' + f'{ctx.author} tried to set the slowmode delay of #{channel} to {humanized_delay}, ' + 'which is not between 0 and 6 hours.' ) @slowmode_group.command(name='reset', aliases=['r']) diff --git a/bot/converters.py b/bot/converters.py index 4deb59f87..65963f513 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -181,8 +181,8 @@ class TagContentConverter(Converter): return tag_content -class Duration(Converter): - """Convert duration strings into UTC datetime.datetime objects.""" +class DurationDelta(Converter): + """Convert duration strings into dateutil.relativedelta.relativedelta objects.""" duration_parser = re.compile( r"((?P\d+?) ?(years|year|Y|y) ?)?" @@ -194,9 +194,9 @@ class Duration(Converter): r"((?P\d+?) ?(seconds|second|S|s))?" ) - async def convert(self, ctx: Context, duration: str) -> datetime: + async def convert(self, ctx: Context, duration: str) -> relativedelta: """ - Converts a `duration` string to a datetime object that's `duration` in the future. + Converts a `duration` string to a relativedelta object. The converter supports the following symbols for each unit of time: - years: `Y`, `y`, `year`, `years` @@ -215,6 +215,20 @@ class Duration(Converter): duration_dict = {unit: int(amount) for unit, amount in match.groupdict(default=0).items()} delta = relativedelta(**duration_dict) + + return delta + + +class Duration(DurationDelta): + """Convert duration strings into UTC datetime.datetime objects.""" + + async def convert(self, ctx: Context, duration: str) -> datetime: + """ + Converts a `duration` string to a datetime object that's `duration` in the future. + + The converter supports the same symbols for each unit of time as its parent class. + """ + delta = super().convert(ctx, duration) now = datetime.utcnow() try: -- cgit v1.2.3 From 7eb3a5a7c1a38ad56f1e9584a24f2da9f00d0a40 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Wed, 1 Jul 2020 17:03:02 +0000 Subject: Forgot an await in the Duration converter --- bot/converters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/converters.py b/bot/converters.py index 65963f513..898822165 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -228,7 +228,7 @@ class Duration(DurationDelta): The converter supports the same symbols for each unit of time as its parent class. """ - delta = super().convert(ctx, duration) + delta = await super().convert(ctx, duration) now = datetime.utcnow() try: -- cgit v1.2.3 From 933a154ccbb83c4ee5ad1fa87e1bea9d8c012f27 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Wed, 1 Jul 2020 17:14:46 +0000 Subject: Catch TypeError when the slowmode delay is 0 seconds --- bot/cogs/slowmode.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index 898f4bf52..b8b3bb65c 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -29,7 +29,15 @@ class Slowmode(Cog): async def get_slowmode(self, ctx: Context, channel: TextChannel) -> None: """Get the slowmode delay for a given text channel.""" delay = relativedelta(seconds=channel.slowmode_delay) - await ctx.send(f'The slowmode delay for {channel.mention} is {time.humanize_delta(delay, precision=3)}.') + + try: + humanized_delay = time.humanize_delta(delay, precision=3) + + except TypeError: + humanized_delay = '0 seconds' + + finally: + await ctx.send(f'The slowmode delay for {channel.mention} is {humanized_delay}.') @slowmode_group.command(name='set', aliases=['s']) @with_role(*MODERATION_ROLES) -- cgit v1.2.3 From 1906cf7caaf580f37a0d689713d5252d1649f4ec Mon Sep 17 00:00:00 2001 From: Den4200 Date: Wed, 1 Jul 2020 17:17:00 +0000 Subject: Add comment explaining TypeError --- bot/cogs/slowmode.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index b8b3bb65c..7e1bee61d 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -34,6 +34,8 @@ class Slowmode(Cog): humanized_delay = time.humanize_delta(delay, precision=3) except TypeError: + # The slowmode delay is 0 seconds, + # which causes `time.humanize_delta` to raise a TypeError humanized_delay = '0 seconds' finally: -- cgit v1.2.3 From c8bcaff2b7bc5b7a66c0307650d6f72b65eac659 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Wed, 1 Jul 2020 18:06:08 +0000 Subject: Use total_seconds method instead of seconds attribute --- bot/cogs/slowmode.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index 7e1bee61d..c2ca97a7f 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -48,7 +48,7 @@ class Slowmode(Cog): # Convert `dateutil.relativedelta.relativedelta` to `datetime.timedelta` # Must do this to get the delta in a particular unit of time utcnow = datetime.utcnow() - slowmode_delay = (utcnow + delay - utcnow).seconds + slowmode_delay = (utcnow + delay - utcnow).total_seconds() humanized_delay = time.humanize_delta(delay, precision=3) -- cgit v1.2.3 From 36de1ea49bb6597179bf9931adfef41ed59e5d5f Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 2 Jul 2020 15:53:22 +0300 Subject: Help System: Implement question message pinning --- bot/cogs/help_channels.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 187adfe51..bb97759ee 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -113,6 +113,10 @@ class HelpChannels(Scheduler, commands.Cog): # RedisCache[discord.TextChannel.id, UtcPosixTimestamp] claim_times = RedisCache() + # This cache maps a help channel to original question message in same channel. + # RedisCache[discord.TextChannel.id, discord.Message.id] + question_messages = RedisCache() + def __init__(self, bot: Bot): super().__init__() @@ -548,6 +552,22 @@ class HelpChannels(Scheduler, commands.Cog): A caller argument is provided for metrics. """ + msg_id = await self.question_messages.pop(channel.id) + + # When message ID exist in cache, try to get it from cache first. When this fail, use API request. + # When this return 404, this mean that message is deleted and can't be unpinned. + if msg_id: + msg = discord.utils.get(self.bot.cached_messages, id=msg_id) + if msg is None: + try: + msg = await channel.fetch_message(msg_id) + except discord.NotFound: + log.debug(f"Can't unpin message {msg_id} because this is deleted.") + + # When we got message, then unpin it + if msg: + await msg.unpin() + log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") await self.move_to_bottom_position( @@ -688,6 +708,14 @@ class HelpChannels(Scheduler, commands.Cog): log.info(f"Channel #{channel} was claimed by `{message.author.id}`.") await self.move_to_in_use(channel) await self.revoke_send_permissions(message.author) + # Pin message for better access and storage this to cache + try: + await message.pin() + except discord.NotFound: + log.info(f"Pinning message {message.id} ({channel}) failed because message got deleted.") + else: + await self.question_messages.set(channel.id, message.id) + # Add user with channel for dormant check. await self.help_channel_claimants.set(channel.id, message.author.id) -- cgit v1.2.3 From d2732ce299cf2071b92fdf0c1eecbb0f16f0afbd Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 2 Jul 2020 15:52:05 +0200 Subject: Incidents: trace-level log incident embed creation --- bot/cogs/moderation/incidents.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 3a1a3d84e..8970c2c5c 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -54,6 +54,8 @@ def make_embed(incident: discord.Message, outcome: Signal, actioned_by: discord. As mentions in embeds do not ping, we do not need to use `incident.clean_content`. """ + log.trace(f"Creating embed for {incident.id=}") + if outcome is Signal.ACTIONED: colour = Colours.soft_green footer = f"Actioned by {actioned_by}" -- cgit v1.2.3 From 83544ca0f91dd7bc8510e4fc7a64bc73712ddaf8 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 3 Jul 2020 10:47:47 +0200 Subject: Incidents: archive incident attachments There is no handling of file types as explained in the `archive` docstring. Testing indicates that relaying incidents with e.g. a text file attachment is simply a noop in the Discord GUI. If there is at least one attachment, we always only relay the one at index 0, as it is believed the user-sent messages can only contain one attachment at maximum. This also adds an extra test asserting the behaviour when an incident with an attachment is archived. The existing test for `archive` is adjusted to assume no attachments. Joe helped me conceive & test this. Co-authored-by: Joseph Banks --- bot/cogs/moderation/incidents.py | 21 +++++++++++++++++++- tests/bot/cogs/moderation/test_incidents.py | 30 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 8970c2c5c..1a12c8bbd 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -186,22 +186,41 @@ class Incidents(Cog): The following pieces of information are relayed: * Incident message content (as embed description) + * Incident attachment (if image, shown in archive embed) * Incident author name (as webhook author) * Incident author avatar (as webhook avatar) * Resolution signal `outcome` (as embed colour & footer) * Moderator `actioned_by` (name & discriminator shown in footer) + If `incident` contains an attachment, we try to add it to the archive embed. There is + no handing of extensions / file types - we simply dispatch the attachment file with the + webhook, and try to display it in the embed. Testing indicates that if the attachment + cannot be displayed (e.g. a text file), it's invisible in the embed, with no error. + Return True if the relay finishes successfully. If anything goes wrong, meaning not all information was relayed, return False. This signals that the original message is not safe to be deleted, as we will lose some information. """ log.debug(f"Archiving incident: {incident.id} (outcome: {outcome}, actioned by: {actioned_by})") + embed = make_embed(incident, outcome, actioned_by) + + # If the incident had an attachment, we will try to relay it + if incident.attachments: + attachment = incident.attachments[0] # User-sent messages can only contain one attachment + log.debug(f"Attempting to archive incident attachment: {attachment.filename}") + + attachment_file = await attachment.to_file() # The file will be sent with the webhook + embed.set_image(url=f"attachment://{attachment.filename}") # Embed displays the attached file + else: + attachment_file = None + try: webhook = await self.bot.fetch_webhook(Webhooks.incidents_archive) await webhook.send( - embed=make_embed(incident, outcome, actioned_by), + embed=embed, username=sub_clyde(incident.author.name), avatar_url=incident.author.avatar_url, + file=attachment_file, ) except Exception: log.exception(f"Failed to archive incident {incident.id} to #incidents-archive") diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 70dfe6b5f..f8d479cef 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -323,6 +323,7 @@ class TestArchive(TestIncidents): content="this is an incident", author=MockUser(name="author_name", avatar_url="author_avatar"), id=123, + attachments=[], # This incident has no attachments ) built_embed = MagicMock(discord.Embed, id=123) # We patch `make_embed` to return this @@ -334,9 +335,38 @@ class TestArchive(TestIncidents): embed=built_embed, username="author_name", avatar_url="author_avatar", + file=None, ) self.assertTrue(archive_return) + async def test_archive_relays_incident_with_attachments(self): + """ + Incident attachments are relayed and displayed in the embed. + + This test asserts the two things that need to happen in order to relay the attachment. + The embed returned by `make_embed` must have the `set_image` method called with the + attachment's filename, and the file must be passed to the webhook's send method. + """ + attachment_file = MagicMock(discord.File) + attachment = MagicMock( + discord.Attachment, + filename="abc.png", + to_file=AsyncMock(return_value=attachment_file), + ) + incident = MockMessage( + attachments=[attachment], + ) + built_embed = MagicMock(discord.Embed) + + with patch("bot.cogs.moderation.incidents.make_embed", MagicMock(return_value=built_embed)): + await self.cog_instance.archive(incident, incidents.Signal.ACTIONED, actioned_by=MockMember()) + + built_embed.set_image.assert_called_once_with(url="attachment://abc.png") + + send_kwargs = self.cog_instance.bot.fetch_webhook.return_value.send.call_args.kwargs + self.assertIn("file", send_kwargs) + self.assertIs(send_kwargs["file"], attachment_file) + async def test_archive_clyde_username(self): """ The archive webhook username is cleansed using `sub_clyde`. -- cgit v1.2.3 From e7be2215dc0c800655c9985d655d5d6d687932f0 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Fri, 3 Jul 2020 15:51:41 +0000 Subject: Remove precision kwarg usage --- bot/cogs/slowmode.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index c2ca97a7f..9f69d30e0 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -30,16 +30,13 @@ class Slowmode(Cog): """Get the slowmode delay for a given text channel.""" delay = relativedelta(seconds=channel.slowmode_delay) - try: - humanized_delay = time.humanize_delta(delay, precision=3) - - except TypeError: - # The slowmode delay is 0 seconds, - # which causes `time.humanize_delta` to raise a TypeError + # Say "0 seconds" instead of "less than a second" + if channel.slowmode_delay == 0: humanized_delay = '0 seconds' + else: + humanized_delay = time.humanize_delta(delay) - finally: - await ctx.send(f'The slowmode delay for {channel.mention} is {humanized_delay}.') + await ctx.send(f'The slowmode delay for {channel.mention} is {humanized_delay}.') @slowmode_group.command(name='set', aliases=['s']) @with_role(*MODERATION_ROLES) @@ -50,7 +47,7 @@ class Slowmode(Cog): utcnow = datetime.utcnow() slowmode_delay = (utcnow + delay - utcnow).total_seconds() - humanized_delay = time.humanize_delta(delay, precision=3) + humanized_delay = time.humanize_delta(delay) if 0 <= slowmode_delay <= 21600: await channel.edit(slowmode_delay=slowmode_delay) -- cgit v1.2.3 From 5cfad8c592388bfff4152a684e10f7d8a04e6426 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Fri, 3 Jul 2020 15:53:31 +0000 Subject: Move log to before what it's logging executes. This makes sure the log will be made, since the operations executed are now below it. --- bot/cogs/slowmode.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index 9f69d30e0..593208bea 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -50,31 +50,33 @@ class Slowmode(Cog): humanized_delay = time.humanize_delta(delay) if 0 <= slowmode_delay <= 21600: + log.info(f'{ctx.author} set the slowmode delay for #{channel} to {humanized_delay}.') + await channel.edit(slowmode_delay=slowmode_delay) await ctx.send( f'{Emojis.check_mark} The slowmode delay for {channel.mention} is now {humanized_delay}.' ) - log.info(f'{ctx.author} set the slowmode delay for #{channel} to {humanized_delay}.') - else: - await ctx.send( - f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.' - ) log.info( f'{ctx.author} tried to set the slowmode delay of #{channel} to {humanized_delay}, ' 'which is not between 0 and 6 hours.' ) + await ctx.send( + f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.' + ) + @slowmode_group.command(name='reset', aliases=['r']) @with_role(*MODERATION_ROLES) async def reset_slowmode(self, ctx: Context, channel: TextChannel) -> None: """Reset the slowmode delay for a given text channel to 0 seconds.""" + log.info(f'{ctx.author} reset the slowmode delay for #{channel} to 0 seconds.') + await channel.edit(slowmode_delay=0) await ctx.send( f'{Emojis.check_mark} The slowmode delay for {channel.mention} has been reset to 0 seconds.' ) - log.info(f'{ctx.author} reset the slowmode delay for #{channel} to 0 seconds.') def setup(bot: Bot) -> None: -- cgit v1.2.3 From 7f430c7ca99030c31c019093019139caa6d81d9c Mon Sep 17 00:00:00 2001 From: Den4200 Date: Fri, 3 Jul 2020 22:55:19 +0000 Subject: Only allow moderators to use the entire cog --- bot/cogs/slowmode.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index 593208bea..ec5e9cc0d 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -8,7 +8,7 @@ from discord.ext.commands import Cog, Context, group from bot.bot import Bot from bot.constants import Emojis, MODERATION_ROLES from bot.converters import DurationDelta -from bot.decorators import with_role +from bot.decorators import with_role_check from bot.utils import time log = logging.getLogger(__name__) @@ -39,7 +39,6 @@ class Slowmode(Cog): await ctx.send(f'The slowmode delay for {channel.mention} is {humanized_delay}.') @slowmode_group.command(name='set', aliases=['s']) - @with_role(*MODERATION_ROLES) async def set_slowmode(self, ctx: Context, channel: TextChannel, delay: DurationDelta) -> None: """Set the slowmode delay for a given text channel.""" # Convert `dateutil.relativedelta.relativedelta` to `datetime.timedelta` @@ -68,7 +67,6 @@ class Slowmode(Cog): ) @slowmode_group.command(name='reset', aliases=['r']) - @with_role(*MODERATION_ROLES) async def reset_slowmode(self, ctx: Context, channel: TextChannel) -> None: """Reset the slowmode delay for a given text channel to 0 seconds.""" log.info(f'{ctx.author} reset the slowmode delay for #{channel} to 0 seconds.') @@ -78,6 +76,10 @@ class Slowmode(Cog): f'{Emojis.check_mark} The slowmode delay for {channel.mention} has been reset to 0 seconds.' ) + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + return with_role_check(ctx, *MODERATION_ROLES) + def setup(bot: Bot) -> None: """Load the Slowmode cog.""" -- cgit v1.2.3 From c4c4dfa698321912eb15ff3c1d77d1170968d124 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 00:29:53 +0000 Subject: Create a constant for the max slowmode delay --- bot/cogs/slowmode.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index ec5e9cc0d..830273174 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -13,6 +13,8 @@ from bot.utils import time log = logging.getLogger(__name__) +SLOWMODE_MAX_DELAY = 21600 # seconds + class Slowmode(Cog): """Commands for getting and setting slowmode delays of text channels.""" @@ -48,7 +50,8 @@ class Slowmode(Cog): humanized_delay = time.humanize_delta(delay) - if 0 <= slowmode_delay <= 21600: + # Ensure the delay is within discord's limits + if slowmode_delay <= SLOWMODE_MAX_DELAY: log.info(f'{ctx.author} set the slowmode delay for #{channel} to {humanized_delay}.') await channel.edit(slowmode_delay=slowmode_delay) -- cgit v1.2.3 From 9804e84cdf5903c3aac3783a66b81e5865680c62 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 00:52:41 +0000 Subject: Remove monkeypatch and apply appropriate changes to _stringify_time_unit --- bot/cogs/slowmode.py | 7 +------ bot/utils/time.py | 4 +++- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index 830273174..88f19b2f1 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -31,12 +31,7 @@ class Slowmode(Cog): async def get_slowmode(self, ctx: Context, channel: TextChannel) -> None: """Get the slowmode delay for a given text channel.""" delay = relativedelta(seconds=channel.slowmode_delay) - - # Say "0 seconds" instead of "less than a second" - if channel.slowmode_delay == 0: - humanized_delay = '0 seconds' - else: - humanized_delay = time.humanize_delta(delay) + humanized_delay = time.humanize_delta(delay) await ctx.send(f'The slowmode delay for {channel.mention} is {humanized_delay}.') diff --git a/bot/utils/time.py b/bot/utils/time.py index 77060143c..47e49904b 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -20,7 +20,9 @@ def _stringify_time_unit(value: int, unit: str) -> str: >>> _stringify_time_unit(0, "minutes") "less than a minute" """ - if value == 1: + if unit == "seconds" and value == 0: + return "0 seconds" + elif value == 1: return f"{value} {unit[:-1]}" elif value == 0: return f"less than a {unit[:-1]}" -- cgit v1.2.3 From 539030a1c2a79efe23541704f0026a072ba064ed Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 00:59:15 +0000 Subject: Default to the channel that `slowmode get` was invoked in --- bot/cogs/slowmode.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index 88f19b2f1..7405c1e7f 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -1,5 +1,6 @@ import logging from datetime import datetime +from typing import Optional from dateutil.relativedelta import relativedelta from discord import TextChannel @@ -28,8 +29,12 @@ class Slowmode(Cog): await ctx.send_help(ctx.command) @slowmode_group.command(name='get', aliases=['g']) - async def get_slowmode(self, ctx: Context, channel: TextChannel) -> None: + async def get_slowmode(self, ctx: Context, channel: Optional[TextChannel] = None) -> None: """Get the slowmode delay for a given text channel.""" + # Use the channel this command was invoked in if one was not given + if channel is None: + channel = ctx.channel + delay = relativedelta(seconds=channel.slowmode_delay) humanized_delay = time.humanize_delta(delay) -- cgit v1.2.3 From 758568f2d39212737f15b871850597185b254fcd Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 01:02:23 +0000 Subject: Default to the channel that `slowmode reset` was invoked in --- bot/cogs/slowmode.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index 7405c1e7f..0b9b64976 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -70,8 +70,12 @@ class Slowmode(Cog): ) @slowmode_group.command(name='reset', aliases=['r']) - async def reset_slowmode(self, ctx: Context, channel: TextChannel) -> None: + async def reset_slowmode(self, ctx: Context, channel: Optional[TextChannel] = None) -> None: """Reset the slowmode delay for a given text channel to 0 seconds.""" + # Use the channel this command was invoked in if one was not given + if channel is None: + channel = ctx.channel + log.info(f'{ctx.author} reset the slowmode delay for #{channel} to 0 seconds.') await channel.edit(slowmode_delay=0) -- cgit v1.2.3 From b04c4163f97bb3c811096587ed1db51d9754114b Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 01:41:21 +0000 Subject: Default to the channel that `slowmode set` was invoked in --- bot/cogs/slowmode.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index 0b9b64976..93ddf4b19 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -41,8 +41,12 @@ class Slowmode(Cog): await ctx.send(f'The slowmode delay for {channel.mention} is {humanized_delay}.') @slowmode_group.command(name='set', aliases=['s']) - async def set_slowmode(self, ctx: Context, channel: TextChannel, delay: DurationDelta) -> None: + async def set_slowmode(self, ctx: Context, channel: Optional[TextChannel], delay: DurationDelta) -> None: """Set the slowmode delay for a given text channel.""" + # Use the channel this command was invoked in if one was not given + if not channel: + channel = ctx.channel + # Convert `dateutil.relativedelta.relativedelta` to `datetime.timedelta` # Must do this to get the delta in a particular unit of time utcnow = datetime.utcnow() -- cgit v1.2.3 From 76e8eaea958029fa11849624c0eb9edcfe248529 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 01:48:23 +0000 Subject: Make channel comparison against None consistent --- bot/cogs/slowmode.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index 93ddf4b19..ecbc235a0 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -44,7 +44,7 @@ class Slowmode(Cog): async def set_slowmode(self, ctx: Context, channel: Optional[TextChannel], delay: DurationDelta) -> None: """Set the slowmode delay for a given text channel.""" # Use the channel this command was invoked in if one was not given - if not channel: + if channel is None: channel = ctx.channel # Convert `dateutil.relativedelta.relativedelta` to `datetime.timedelta` -- cgit v1.2.3 From 7c4f6db3f7291612862f6f16cddc73f7add72fd0 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 01:50:08 +0000 Subject: Remove unneeded kwargs for `typing.Optional` to keep consistency --- bot/cogs/slowmode.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index ecbc235a0..1e83065ab 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -29,7 +29,7 @@ class Slowmode(Cog): await ctx.send_help(ctx.command) @slowmode_group.command(name='get', aliases=['g']) - async def get_slowmode(self, ctx: Context, channel: Optional[TextChannel] = None) -> None: + async def get_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None: """Get the slowmode delay for a given text channel.""" # Use the channel this command was invoked in if one was not given if channel is None: @@ -74,7 +74,7 @@ class Slowmode(Cog): ) @slowmode_group.command(name='reset', aliases=['r']) - async def reset_slowmode(self, ctx: Context, channel: Optional[TextChannel] = None) -> None: + async def reset_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None: """Reset the slowmode delay for a given text channel to 0 seconds.""" # Use the channel this command was invoked in if one was not given if channel is None: -- cgit v1.2.3 From f31babf54ef1e4d2d2966bf8b695b1e4a01848e0 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 02:04:30 +0000 Subject: Update the docstrings to account for optional channel parameter --- bot/cogs/slowmode.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index 1e83065ab..1d055afac 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -25,12 +25,12 @@ class Slowmode(Cog): @group(name='slowmode', aliases=['sm'], invoke_without_command=True) async def slowmode_group(self, ctx: Context) -> None: - """Get and set the slowmode delay for a given text channel.""" + """Get or set the slowmode delay for the text channel this was invoked in or a given text channel.""" await ctx.send_help(ctx.command) @slowmode_group.command(name='get', aliases=['g']) async def get_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None: - """Get the slowmode delay for a given text channel.""" + """Get the slowmode delay for a text channel.""" # Use the channel this command was invoked in if one was not given if channel is None: channel = ctx.channel @@ -42,7 +42,7 @@ class Slowmode(Cog): @slowmode_group.command(name='set', aliases=['s']) async def set_slowmode(self, ctx: Context, channel: Optional[TextChannel], delay: DurationDelta) -> None: - """Set the slowmode delay for a given text channel.""" + """Set the slowmode delay for a text channel.""" # Use the channel this command was invoked in if one was not given if channel is None: channel = ctx.channel @@ -75,7 +75,7 @@ class Slowmode(Cog): @slowmode_group.command(name='reset', aliases=['r']) async def reset_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None: - """Reset the slowmode delay for a given text channel to 0 seconds.""" + """Reset the slowmode delay for a text channel to 0 seconds.""" # Use the channel this command was invoked in if one was not given if channel is None: channel = ctx.channel -- cgit v1.2.3 From 40719793f9c0d8a2c5761d3730b5920a146709c3 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 04:08:27 +0000 Subject: Add tests for cog_check and get_slowmode --- tests/bot/cogs/test_slowmode.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 tests/bot/cogs/test_slowmode.py diff --git a/tests/bot/cogs/test_slowmode.py b/tests/bot/cogs/test_slowmode.py new file mode 100644 index 000000000..fb9f3c9ad --- /dev/null +++ b/tests/bot/cogs/test_slowmode.py @@ -0,0 +1,37 @@ +import unittest +from unittest import mock + +from bot.cogs.slowmode import Slowmode +from tests.helpers import MockBot, MockContext, MockTextChannel + + +class SlowmodeTests(unittest.IsolatedAsyncioTestCase): + + def setUp(self) -> None: + self.bot = MockBot() + self.cog = Slowmode(self.bot) + self.text_channel = MockTextChannel() + self.ctx = MockContext(channel=self.text_channel) + + async def test_get_slowmode_no_channel(self) -> None: + """Get slowmode without a given channel""" + self.text_channel.mention = '#python-general' + self.text_channel.slowmode_delay = 5 + + await self.cog.get_slowmode(self.cog, self.ctx, None) + self.ctx.send.assert_called_once_with("The slowmode delay for #python-general is 5 seconds.") + + async def test_get_slowmode_with_channel(self) -> None: + """Get slowmode without a given channel""" + self.text_channel.mention = '#python-language' + self.text_channel.slowmode_delay = 2 + + await self.cog.get_slowmode(self.cog, self.ctx, self.text_channel) + self.ctx.send.assert_called_once_with("The slowmode delay for #python-language is 2 seconds.") + + @mock.patch("bot.cogs.slowmode.with_role_check") + @mock.patch("bot.cogs.slowmode.MODERATION_ROLES", new=(1, 2, 3)) + def test_cog_check(self, role_check): + """Role check is called with `MODERATION_ROLES`""" + self.cog.cog_check(self.ctx) + role_check.assert_called_once_with(self.ctx, *(1, 2, 3)) -- cgit v1.2.3 From e760b4312a5264fe9442cb1d53c9e357dbeb2b81 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 04:55:42 +0000 Subject: Add tests for reset_slowmode --- tests/bot/cogs/test_slowmode.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/test_slowmode.py b/tests/bot/cogs/test_slowmode.py index fb9f3c9ad..a2e5ad346 100644 --- a/tests/bot/cogs/test_slowmode.py +++ b/tests/bot/cogs/test_slowmode.py @@ -2,6 +2,7 @@ import unittest from unittest import mock from bot.cogs.slowmode import Slowmode +from bot.constants import Emojis from tests.helpers import MockBot, MockContext, MockTextChannel @@ -14,7 +15,7 @@ class SlowmodeTests(unittest.IsolatedAsyncioTestCase): self.ctx = MockContext(channel=self.text_channel) async def test_get_slowmode_no_channel(self) -> None: - """Get slowmode without a given channel""" + """Get slowmode without a given channel.""" self.text_channel.mention = '#python-general' self.text_channel.slowmode_delay = 5 @@ -22,12 +23,30 @@ class SlowmodeTests(unittest.IsolatedAsyncioTestCase): self.ctx.send.assert_called_once_with("The slowmode delay for #python-general is 5 seconds.") async def test_get_slowmode_with_channel(self) -> None: - """Get slowmode without a given channel""" + """Get slowmode with a given channel.""" self.text_channel.mention = '#python-language' self.text_channel.slowmode_delay = 2 await self.cog.get_slowmode(self.cog, self.ctx, self.text_channel) - self.ctx.send.assert_called_once_with("The slowmode delay for #python-language is 2 seconds.") + self.ctx.send.assert_called_once_with('The slowmode delay for #python-language is 2 seconds.') + + async def test_reset_slowmode_no_channel(self) -> None: + """Reset slowmode without a given channel.""" + self.text_channel.mention = '#careers' + + await self.cog.reset_slowmode(self.cog, self.ctx, None) + self.ctx.send.assert_called_once_with( + f'{Emojis.check_mark} The slowmode delay for #careers has been reset to 0 seconds.' + ) + + async def test_reset_slowmode_with_channel(self) -> None: + """Reset slowmode with a given channel.""" + self.text_channel.mention = '#meta' + + await self.cog.reset_slowmode(self.cog, self.ctx, self.text_channel) + self.ctx.send.assert_called_once_with( + f'{Emojis.check_mark} The slowmode delay for #meta has been reset to 0 seconds.' + ) @mock.patch("bot.cogs.slowmode.with_role_check") @mock.patch("bot.cogs.slowmode.MODERATION_ROLES", new=(1, 2, 3)) -- cgit v1.2.3 From 8613659cb191bedca925dc798c89623b49c9a90a Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 05:45:04 +0000 Subject: Add tests for set_slowmode --- tests/bot/cogs/test_slowmode.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/bot/cogs/test_slowmode.py b/tests/bot/cogs/test_slowmode.py index a2e5ad346..5262ce34a 100644 --- a/tests/bot/cogs/test_slowmode.py +++ b/tests/bot/cogs/test_slowmode.py @@ -1,6 +1,8 @@ import unittest from unittest import mock +from dateutil.relativedelta import relativedelta + from bot.cogs.slowmode import Slowmode from bot.constants import Emojis from tests.helpers import MockBot, MockContext, MockTextChannel @@ -30,6 +32,24 @@ class SlowmodeTests(unittest.IsolatedAsyncioTestCase): await self.cog.get_slowmode(self.cog, self.ctx, self.text_channel) self.ctx.send.assert_called_once_with('The slowmode delay for #python-language is 2 seconds.') + async def test_set_slowmode_no_channel(self) -> None: + """Set slowmode without a given channel.""" + self.text_channel.mention = '#careers' + + await self.cog.set_slowmode(self.cog, self.ctx, None, relativedelta(seconds=3)) + self.ctx.send.assert_called_once_with( + f'{Emojis.check_mark} The slowmode delay for #careers is now 3 seconds.' + ) + + async def test_set_slowmode_with_channel(self) -> None: + """Set slowmode with a given channel.""" + self.text_channel.mention = '#meta' + + await self.cog.set_slowmode(self.cog, self.ctx, self.text_channel, relativedelta(seconds=4)) + self.ctx.send.assert_called_once_with( + f'{Emojis.check_mark} The slowmode delay for #meta is now 4 seconds.' + ) + async def test_reset_slowmode_no_channel(self) -> None: """Reset slowmode without a given channel.""" self.text_channel.mention = '#careers' -- cgit v1.2.3 From 4935ed5ae632f5887bcff23ac67c781eab8527e9 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 06:05:32 +0000 Subject: Use local text_channel instead of instance attribute --- tests/bot/cogs/test_slowmode.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/tests/bot/cogs/test_slowmode.py b/tests/bot/cogs/test_slowmode.py index 5262ce34a..663c9fd43 100644 --- a/tests/bot/cogs/test_slowmode.py +++ b/tests/bot/cogs/test_slowmode.py @@ -13,28 +13,25 @@ class SlowmodeTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.bot = MockBot() self.cog = Slowmode(self.bot) - self.text_channel = MockTextChannel() - self.ctx = MockContext(channel=self.text_channel) + self.ctx = MockContext() async def test_get_slowmode_no_channel(self) -> None: """Get slowmode without a given channel.""" - self.text_channel.mention = '#python-general' - self.text_channel.slowmode_delay = 5 + self.ctx.channel = MockTextChannel(name='python-general', slowmode_delay=5) await self.cog.get_slowmode(self.cog, self.ctx, None) self.ctx.send.assert_called_once_with("The slowmode delay for #python-general is 5 seconds.") async def test_get_slowmode_with_channel(self) -> None: """Get slowmode with a given channel.""" - self.text_channel.mention = '#python-language' - self.text_channel.slowmode_delay = 2 + text_channel = MockTextChannel(name='python-language', slowmode_delay=2) - await self.cog.get_slowmode(self.cog, self.ctx, self.text_channel) + await self.cog.get_slowmode(self.cog, self.ctx, text_channel) self.ctx.send.assert_called_once_with('The slowmode delay for #python-language is 2 seconds.') async def test_set_slowmode_no_channel(self) -> None: """Set slowmode without a given channel.""" - self.text_channel.mention = '#careers' + self.ctx.channel = MockTextChannel(name='careers') await self.cog.set_slowmode(self.cog, self.ctx, None, relativedelta(seconds=3)) self.ctx.send.assert_called_once_with( @@ -43,16 +40,16 @@ class SlowmodeTests(unittest.IsolatedAsyncioTestCase): async def test_set_slowmode_with_channel(self) -> None: """Set slowmode with a given channel.""" - self.text_channel.mention = '#meta' + text_channel = MockTextChannel(name='meta') - await self.cog.set_slowmode(self.cog, self.ctx, self.text_channel, relativedelta(seconds=4)) + await self.cog.set_slowmode(self.cog, self.ctx, text_channel, relativedelta(seconds=4)) self.ctx.send.assert_called_once_with( f'{Emojis.check_mark} The slowmode delay for #meta is now 4 seconds.' ) async def test_reset_slowmode_no_channel(self) -> None: """Reset slowmode without a given channel.""" - self.text_channel.mention = '#careers' + self.ctx.channel = MockTextChannel(name='careers', slowmode_delay=6) await self.cog.reset_slowmode(self.cog, self.ctx, None) self.ctx.send.assert_called_once_with( @@ -61,9 +58,9 @@ class SlowmodeTests(unittest.IsolatedAsyncioTestCase): async def test_reset_slowmode_with_channel(self) -> None: """Reset slowmode with a given channel.""" - self.text_channel.mention = '#meta' + text_channel = MockTextChannel(name='meta', slowmode_delay=1) - await self.cog.reset_slowmode(self.cog, self.ctx, self.text_channel) + await self.cog.reset_slowmode(self.cog, self.ctx, text_channel) self.ctx.send.assert_called_once_with( f'{Emojis.check_mark} The slowmode delay for #meta has been reset to 0 seconds.' ) -- cgit v1.2.3 From 77a2e514dd2e200e23ccf45760677c2e7c40b9ff Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 06:11:00 +0000 Subject: Add multiple test cases for set_slowmode tests --- tests/bot/cogs/test_slowmode.py | 44 +++++++++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/tests/bot/cogs/test_slowmode.py b/tests/bot/cogs/test_slowmode.py index 663c9fd43..e9835b8bd 100644 --- a/tests/bot/cogs/test_slowmode.py +++ b/tests/bot/cogs/test_slowmode.py @@ -31,22 +31,46 @@ class SlowmodeTests(unittest.IsolatedAsyncioTestCase): async def test_set_slowmode_no_channel(self) -> None: """Set slowmode without a given channel.""" - self.ctx.channel = MockTextChannel(name='careers') - - await self.cog.set_slowmode(self.cog, self.ctx, None, relativedelta(seconds=3)) - self.ctx.send.assert_called_once_with( - f'{Emojis.check_mark} The slowmode delay for #careers is now 3 seconds.' + test_cases = ( + ('helpers', 23, f'{Emojis.check_mark} The slowmode delay for #helpers is now 23 seconds.'), + ('mods', 76526, f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.'), + ('admins', 97, f'{Emojis.check_mark} The slowmode delay for #admins is now 1 minute and 37 seconds.') ) + for channel_name, seconds, result_msg in test_cases: + with self.subTest( + channel_mention=channel_name, + seconds=seconds, + result_msg=result_msg + ): + self.ctx.channel = MockTextChannel(name=channel_name) + + await self.cog.set_slowmode(self.cog, self.ctx, None, relativedelta(seconds=seconds)) + self.ctx.send.assert_called_once_with(result_msg) + + self.ctx.reset_mock() + async def test_set_slowmode_with_channel(self) -> None: """Set slowmode with a given channel.""" - text_channel = MockTextChannel(name='meta') - - await self.cog.set_slowmode(self.cog, self.ctx, text_channel, relativedelta(seconds=4)) - self.ctx.send.assert_called_once_with( - f'{Emojis.check_mark} The slowmode delay for #meta is now 4 seconds.' + test_cases = ( + ('bot-commands', 12, f'{Emojis.check_mark} The slowmode delay for #bot-commands is now 12 seconds.'), + ('mod-spam', 21, f'{Emojis.check_mark} The slowmode delay for #mod-spam is now 21 seconds.'), + ('admin-spam', 4323598, f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.') ) + for channel_name, seconds, result_msg in test_cases: + with self.subTest( + channel_mention=channel_name, + seconds=seconds, + result_msg=result_msg + ): + text_channel = MockTextChannel(name=channel_name) + + await self.cog.set_slowmode(self.cog, self.ctx, text_channel, relativedelta(seconds=seconds)) + self.ctx.send.assert_called_once_with(result_msg) + + self.ctx.reset_mock() + async def test_reset_slowmode_no_channel(self) -> None: """Reset slowmode without a given channel.""" self.ctx.channel = MockTextChannel(name='careers', slowmode_delay=6) -- cgit v1.2.3 From 604c6a7a09d7826870fb384b98e0a6d1463721b4 Mon Sep 17 00:00:00 2001 From: Karlis S Date: Mon, 6 Jul 2020 14:24:55 +0000 Subject: Restore newlines for `notify_infraction` embed description Truncate reason instead full content to avoid removing newlines --- bot/cogs/moderation/utils.py | 6 +++--- tests/bot/cogs/moderation/test_utils.py | 22 +++++++++++----------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 8b36210be..95820404a 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -35,7 +35,7 @@ INFRACTION_APPEAL_FOOTER = f"To appeal this infraction, send an e-mail to {APPEA INFRACTION_AUTHOR_NAME = "Infraction information" INFRACTION_DESCRIPTION_TEMPLATE = ( - "\n**Type:** {type}\n" + "**Type:** {type}\n" "**Expires:** {expires}\n" "**Reason:** {reason}\n" ) @@ -157,11 +157,11 @@ async def notify_infraction( text = INFRACTION_DESCRIPTION_TEMPLATE.format( type=infr_type.capitalize(), expires=expires_at or "N/A", - reason=reason or "No reason provided." + reason=textwrap.shorten(reason, 1000, placeholder="...") if reason else "No reason provided." ) embed = discord.Embed( - description=textwrap.shorten(text, width=2048, placeholder="..."), + description=text, colour=Colours.soft_red ) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 029719669..c9a4e4040 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -136,11 +136,11 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "args": (self.user, "ban", "2020-02-26 09:20 (23 hours and 59 minutes)"), "expected_output": Embed( title=utils.INFRACTION_TITLE, - description=textwrap.shorten(utils.INFRACTION_DESCRIPTION_TEMPLATE.format( + description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( type="Ban", expires="2020-02-26 09:20 (23 hours and 59 minutes)", reason="No reason provided." - ), width=2048, placeholder="..."), + ), colour=Colours.soft_red, url=utils.RULES_URL ).set_author( @@ -154,11 +154,11 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "args": (self.user, "warning", None, "Test reason."), "expected_output": Embed( title=utils.INFRACTION_TITLE, - description=textwrap.shorten(utils.INFRACTION_DESCRIPTION_TEMPLATE.format( + description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( type="Warning", expires="N/A", reason="Test reason." - ), width=2048, placeholder="..."), + ), colour=Colours.soft_red, url=utils.RULES_URL ).set_author( @@ -172,11 +172,11 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "args": (self.user, "note", None, None, Icons.defcon_denied), "expected_output": Embed( title=utils.INFRACTION_TITLE, - description=textwrap.shorten(utils.INFRACTION_DESCRIPTION_TEMPLATE.format( + description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( type="Note", expires="N/A", reason="No reason provided." - ), width=2048, placeholder="..."), + ), colour=Colours.soft_red, url=utils.RULES_URL ).set_author( @@ -190,11 +190,11 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "args": (self.user, "mute", "2020-02-26 09:20 (23 hours and 59 minutes)", "Test", Icons.defcon_denied), "expected_output": Embed( title=utils.INFRACTION_TITLE, - description=textwrap.shorten(utils.INFRACTION_DESCRIPTION_TEMPLATE.format( + description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( type="Mute", expires="2020-02-26 09:20 (23 hours and 59 minutes)", reason="Test" - ), width=2048, placeholder="..."), + ), colour=Colours.soft_red, url=utils.RULES_URL ).set_author( @@ -208,11 +208,11 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "args": (self.user, "mute", None, "foo bar" * 4000, Icons.defcon_denied), "expected_output": Embed( title=utils.INFRACTION_TITLE, - description=textwrap.shorten(utils.INFRACTION_DESCRIPTION_TEMPLATE.format( + description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( type="Mute", expires="N/A", - reason="foo bar" * 4000 - ), width=2048, placeholder="..."), + reason=textwrap.shorten("foo bar" * 4000, 1000, placeholder="...") + ), colour=Colours.soft_red, url=utils.RULES_URL ).set_author( -- cgit v1.2.3 From 2d170b8af92c77bedea4d77fbdeedc515d3f2c59 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 17:08:24 +0000 Subject: Improve set_slowmode tests by checking whether the channel was edited --- tests/bot/cogs/test_slowmode.py | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/tests/bot/cogs/test_slowmode.py b/tests/bot/cogs/test_slowmode.py index e9835b8bd..65b1534cb 100644 --- a/tests/bot/cogs/test_slowmode.py +++ b/tests/bot/cogs/test_slowmode.py @@ -32,20 +32,27 @@ class SlowmodeTests(unittest.IsolatedAsyncioTestCase): async def test_set_slowmode_no_channel(self) -> None: """Set slowmode without a given channel.""" test_cases = ( - ('helpers', 23, f'{Emojis.check_mark} The slowmode delay for #helpers is now 23 seconds.'), - ('mods', 76526, f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.'), - ('admins', 97, f'{Emojis.check_mark} The slowmode delay for #admins is now 1 minute and 37 seconds.') + ('helpers', 23, True, f'{Emojis.check_mark} The slowmode delay for #helpers is now 23 seconds.'), + ('mods', 76526, False, f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.'), + ('admins', 97, True, f'{Emojis.check_mark} The slowmode delay for #admins is now 1 minute and 37 seconds.') ) - for channel_name, seconds, result_msg in test_cases: + for channel_name, seconds, edited, result_msg in test_cases: with self.subTest( channel_mention=channel_name, seconds=seconds, + edited=edited, result_msg=result_msg ): self.ctx.channel = MockTextChannel(name=channel_name) await self.cog.set_slowmode(self.cog, self.ctx, None, relativedelta(seconds=seconds)) + + if edited: + self.ctx.channel.edit.assert_awaited_once_with(slowmode_delay=float(seconds)) + else: + self.ctx.channel.edit.assert_not_called() + self.ctx.send.assert_called_once_with(result_msg) self.ctx.reset_mock() @@ -53,20 +60,27 @@ class SlowmodeTests(unittest.IsolatedAsyncioTestCase): async def test_set_slowmode_with_channel(self) -> None: """Set slowmode with a given channel.""" test_cases = ( - ('bot-commands', 12, f'{Emojis.check_mark} The slowmode delay for #bot-commands is now 12 seconds.'), - ('mod-spam', 21, f'{Emojis.check_mark} The slowmode delay for #mod-spam is now 21 seconds.'), - ('admin-spam', 4323598, f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.') + ('bot-commands', 12, True, f'{Emojis.check_mark} The slowmode delay for #bot-commands is now 12 seconds.'), + ('mod-spam', 21, True, f'{Emojis.check_mark} The slowmode delay for #mod-spam is now 21 seconds.'), + ('admin-spam', 4323598, False, f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.') ) - for channel_name, seconds, result_msg in test_cases: + for channel_name, seconds, edited, result_msg in test_cases: with self.subTest( channel_mention=channel_name, seconds=seconds, + edited=edited, result_msg=result_msg ): text_channel = MockTextChannel(name=channel_name) await self.cog.set_slowmode(self.cog, self.ctx, text_channel, relativedelta(seconds=seconds)) + + if edited: + text_channel.edit.assert_awaited_once_with(slowmode_delay=float(seconds)) + else: + text_channel.edit.assert_not_called() + self.ctx.send.assert_called_once_with(result_msg) self.ctx.reset_mock() -- cgit v1.2.3 From 14cfd1e9dd4d149fb554b84969fed27f85ad5361 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 6 Jul 2020 10:09:03 -0700 Subject: Scheduler: assert the coroutine hasn't been awaited yet It'd fail to schedule the coroutine otherwise anyway. There is also the potential to close the coroutine, which may be unexpected to see for a coroutine that was already running (despite being documented). --- bot/utils/scheduling.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 9fc519393..fddb0c2fe 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -43,6 +43,9 @@ class Scheduler: """ self._log.trace(f"Scheduling task #{task_id}...") + msg = f"Cannot schedule an already started coroutine for #{task_id}" + assert inspect.getcoroutinestate(coroutine) == "CORO_CREATED", msg + if task_id in self._scheduled_tasks: self._log.debug(f"Did not schedule task #{task_id}; task was already scheduled.") coroutine.close() -- cgit v1.2.3 From 420171bc5d472868f5fb96c8960731eea4d67c5d Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 17:15:45 +0000 Subject: Move slowmode cog to the moderation subpackage --- bot/__main__.py | 1 - bot/cogs/moderation/__init__.py | 4 +- bot/cogs/moderation/slowmode.py | 97 +++++++++++++++++++++++++++++++++++++++++ bot/cogs/slowmode.py | 97 ----------------------------------------- 4 files changed, 100 insertions(+), 99 deletions(-) create mode 100644 bot/cogs/moderation/slowmode.py delete mode 100644 bot/cogs/slowmode.py diff --git a/bot/__main__.py b/bot/__main__.py index bbd9c9144..4e0d4a111 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -62,7 +62,6 @@ bot.load_extension("bot.cogs.off_topic_names") bot.load_extension("bot.cogs.reddit") bot.load_extension("bot.cogs.reminders") bot.load_extension("bot.cogs.site") -bot.load_extension("bot.cogs.slowmode") bot.load_extension("bot.cogs.snekbox") bot.load_extension("bot.cogs.stats") bot.load_extension("bot.cogs.sync") diff --git a/bot/cogs/moderation/__init__.py b/bot/cogs/moderation/__init__.py index 6880ca1bd..a5c1ef362 100644 --- a/bot/cogs/moderation/__init__.py +++ b/bot/cogs/moderation/__init__.py @@ -3,13 +3,15 @@ from .infractions import Infractions from .management import ModManagement from .modlog import ModLog from .silence import Silence +from .slowmode import Slowmode from .superstarify import Superstarify def setup(bot: Bot) -> None: - """Load the Infractions, ModManagement, ModLog, Silence, and Superstarify cogs.""" + """Load the Infractions, ModManagement, ModLog, Silence, Slowmode, and Superstarify cogs.""" bot.add_cog(Infractions(bot)) bot.add_cog(ModLog(bot)) bot.add_cog(ModManagement(bot)) bot.add_cog(Silence(bot)) + bot.add_cog(Slowmode(bot)) bot.add_cog(Superstarify(bot)) diff --git a/bot/cogs/moderation/slowmode.py b/bot/cogs/moderation/slowmode.py new file mode 100644 index 000000000..1d055afac --- /dev/null +++ b/bot/cogs/moderation/slowmode.py @@ -0,0 +1,97 @@ +import logging +from datetime import datetime +from typing import Optional + +from dateutil.relativedelta import relativedelta +from discord import TextChannel +from discord.ext.commands import Cog, Context, group + +from bot.bot import Bot +from bot.constants import Emojis, MODERATION_ROLES +from bot.converters import DurationDelta +from bot.decorators import with_role_check +from bot.utils import time + +log = logging.getLogger(__name__) + +SLOWMODE_MAX_DELAY = 21600 # seconds + + +class Slowmode(Cog): + """Commands for getting and setting slowmode delays of text channels.""" + + def __init__(self, bot: Bot) -> None: + self.bot = bot + + @group(name='slowmode', aliases=['sm'], invoke_without_command=True) + async def slowmode_group(self, ctx: Context) -> None: + """Get or set the slowmode delay for the text channel this was invoked in or a given text channel.""" + await ctx.send_help(ctx.command) + + @slowmode_group.command(name='get', aliases=['g']) + async def get_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None: + """Get the slowmode delay for a text channel.""" + # Use the channel this command was invoked in if one was not given + if channel is None: + channel = ctx.channel + + delay = relativedelta(seconds=channel.slowmode_delay) + humanized_delay = time.humanize_delta(delay) + + await ctx.send(f'The slowmode delay for {channel.mention} is {humanized_delay}.') + + @slowmode_group.command(name='set', aliases=['s']) + async def set_slowmode(self, ctx: Context, channel: Optional[TextChannel], delay: DurationDelta) -> None: + """Set the slowmode delay for a text channel.""" + # Use the channel this command was invoked in if one was not given + if channel is None: + channel = ctx.channel + + # Convert `dateutil.relativedelta.relativedelta` to `datetime.timedelta` + # Must do this to get the delta in a particular unit of time + utcnow = datetime.utcnow() + slowmode_delay = (utcnow + delay - utcnow).total_seconds() + + humanized_delay = time.humanize_delta(delay) + + # Ensure the delay is within discord's limits + if slowmode_delay <= SLOWMODE_MAX_DELAY: + log.info(f'{ctx.author} set the slowmode delay for #{channel} to {humanized_delay}.') + + await channel.edit(slowmode_delay=slowmode_delay) + await ctx.send( + f'{Emojis.check_mark} The slowmode delay for {channel.mention} is now {humanized_delay}.' + ) + + else: + log.info( + f'{ctx.author} tried to set the slowmode delay of #{channel} to {humanized_delay}, ' + 'which is not between 0 and 6 hours.' + ) + + await ctx.send( + f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.' + ) + + @slowmode_group.command(name='reset', aliases=['r']) + async def reset_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None: + """Reset the slowmode delay for a text channel to 0 seconds.""" + # Use the channel this command was invoked in if one was not given + if channel is None: + channel = ctx.channel + + log.info(f'{ctx.author} reset the slowmode delay for #{channel} to 0 seconds.') + + await channel.edit(slowmode_delay=0) + await ctx.send( + f'{Emojis.check_mark} The slowmode delay for {channel.mention} has been reset to 0 seconds.' + ) + + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + return with_role_check(ctx, *MODERATION_ROLES) + + +def setup(bot: Bot) -> None: + """Load the Slowmode cog.""" + bot.add_cog(Slowmode(bot)) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py deleted file mode 100644 index 1d055afac..000000000 --- a/bot/cogs/slowmode.py +++ /dev/null @@ -1,97 +0,0 @@ -import logging -from datetime import datetime -from typing import Optional - -from dateutil.relativedelta import relativedelta -from discord import TextChannel -from discord.ext.commands import Cog, Context, group - -from bot.bot import Bot -from bot.constants import Emojis, MODERATION_ROLES -from bot.converters import DurationDelta -from bot.decorators import with_role_check -from bot.utils import time - -log = logging.getLogger(__name__) - -SLOWMODE_MAX_DELAY = 21600 # seconds - - -class Slowmode(Cog): - """Commands for getting and setting slowmode delays of text channels.""" - - def __init__(self, bot: Bot) -> None: - self.bot = bot - - @group(name='slowmode', aliases=['sm'], invoke_without_command=True) - async def slowmode_group(self, ctx: Context) -> None: - """Get or set the slowmode delay for the text channel this was invoked in or a given text channel.""" - await ctx.send_help(ctx.command) - - @slowmode_group.command(name='get', aliases=['g']) - async def get_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None: - """Get the slowmode delay for a text channel.""" - # Use the channel this command was invoked in if one was not given - if channel is None: - channel = ctx.channel - - delay = relativedelta(seconds=channel.slowmode_delay) - humanized_delay = time.humanize_delta(delay) - - await ctx.send(f'The slowmode delay for {channel.mention} is {humanized_delay}.') - - @slowmode_group.command(name='set', aliases=['s']) - async def set_slowmode(self, ctx: Context, channel: Optional[TextChannel], delay: DurationDelta) -> None: - """Set the slowmode delay for a text channel.""" - # Use the channel this command was invoked in if one was not given - if channel is None: - channel = ctx.channel - - # Convert `dateutil.relativedelta.relativedelta` to `datetime.timedelta` - # Must do this to get the delta in a particular unit of time - utcnow = datetime.utcnow() - slowmode_delay = (utcnow + delay - utcnow).total_seconds() - - humanized_delay = time.humanize_delta(delay) - - # Ensure the delay is within discord's limits - if slowmode_delay <= SLOWMODE_MAX_DELAY: - log.info(f'{ctx.author} set the slowmode delay for #{channel} to {humanized_delay}.') - - await channel.edit(slowmode_delay=slowmode_delay) - await ctx.send( - f'{Emojis.check_mark} The slowmode delay for {channel.mention} is now {humanized_delay}.' - ) - - else: - log.info( - f'{ctx.author} tried to set the slowmode delay of #{channel} to {humanized_delay}, ' - 'which is not between 0 and 6 hours.' - ) - - await ctx.send( - f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.' - ) - - @slowmode_group.command(name='reset', aliases=['r']) - async def reset_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None: - """Reset the slowmode delay for a text channel to 0 seconds.""" - # Use the channel this command was invoked in if one was not given - if channel is None: - channel = ctx.channel - - log.info(f'{ctx.author} reset the slowmode delay for #{channel} to 0 seconds.') - - await channel.edit(slowmode_delay=0) - await ctx.send( - f'{Emojis.check_mark} The slowmode delay for {channel.mention} has been reset to 0 seconds.' - ) - - def cog_check(self, ctx: Context) -> bool: - """Only allow moderators to invoke the commands in this cog.""" - return with_role_check(ctx, *MODERATION_ROLES) - - -def setup(bot: Bot) -> None: - """Load the Slowmode cog.""" - bot.add_cog(Slowmode(bot)) -- cgit v1.2.3 From 30114ac8c118220b743d4a91f737f8ad973eeb9c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 6 Jul 2020 10:10:47 -0700 Subject: Scheduler: document coroutine closing elsewhere --- bot/utils/scheduling.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index fddb0c2fe..03f31d78f 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -36,10 +36,10 @@ class Scheduler: def schedule(self, task_id: t.Hashable, coroutine: t.Coroutine) -> None: """ - Schedule the execution of a coroutine. + Schedule the execution of a `coroutine`. - If a task with `task_id` already exists, close `coroutine` instead of scheduling it. - This prevents unawaited coroutine warnings. + If a task with `task_id` already exists, close `coroutine` instead of scheduling it. This + prevents unawaited coroutine warnings. Don't pass a coroutine that'll be re-used elsewhere. """ self._log.trace(f"Scheduling task #{task_id}...") @@ -62,6 +62,9 @@ class Scheduler: Schedule `coroutine` to be executed at the given naïve UTC `time`. If `time` is in the past, schedule `coroutine` immediately. + + If a task with `task_id` already exists, close `coroutine` instead of scheduling it. This + prevents unawaited coroutine warnings. Don't pass a coroutine that'll be re-used elsewhere. """ delay = (time - datetime.utcnow()).total_seconds() if delay > 0: @@ -70,7 +73,12 @@ class Scheduler: self.schedule(task_id, coroutine) def schedule_later(self, delay: t.Union[int, float], task_id: t.Hashable, coroutine: t.Coroutine) -> None: - """Schedule `coroutine` to be executed after the given `delay` number of seconds.""" + """ + Schedule `coroutine` to be executed after the given `delay` number of seconds. + + If a task with `task_id` already exists, close `coroutine` instead of scheduling it. This + prevents unawaited coroutine warnings. Don't pass a coroutine that'll be re-used elsewhere. + """ self.schedule(task_id, self._await_later(delay, task_id, coroutine)) def cancel(self, task_id: t.Hashable) -> None: -- cgit v1.2.3 From cdeb41bfd283cb6cb1285993737e8e3abd5aea9f Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 17:30:44 +0000 Subject: Fix imports in slowmode tests --- tests/bot/cogs/test_slowmode.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/test_slowmode.py b/tests/bot/cogs/test_slowmode.py index 65b1534cb..f442814c8 100644 --- a/tests/bot/cogs/test_slowmode.py +++ b/tests/bot/cogs/test_slowmode.py @@ -3,7 +3,7 @@ from unittest import mock from dateutil.relativedelta import relativedelta -from bot.cogs.slowmode import Slowmode +from bot.cogs.moderation.slowmode import Slowmode from bot.constants import Emojis from tests.helpers import MockBot, MockContext, MockTextChannel @@ -103,8 +103,8 @@ class SlowmodeTests(unittest.IsolatedAsyncioTestCase): f'{Emojis.check_mark} The slowmode delay for #meta has been reset to 0 seconds.' ) - @mock.patch("bot.cogs.slowmode.with_role_check") - @mock.patch("bot.cogs.slowmode.MODERATION_ROLES", new=(1, 2, 3)) + @mock.patch("bot.cogs.moderation.slowmode.with_role_check") + @mock.patch("bot.cogs.moderation.slowmode.MODERATION_ROLES", new=(1, 2, 3)) def test_cog_check(self, role_check): """Role check is called with `MODERATION_ROLES`""" self.cog.cog_check(self.ctx) -- cgit v1.2.3 From b1c017741318ff0e96e4a46d0390054541a215d1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 7 Jul 2020 12:01:50 -0700 Subject: Prevent bot from mentioning roles This was open to abuse when the bot relayed user input. --- Pipfile | 2 +- Pipfile.lock | 220 ++++++++++++++++++++++++++++++++------------------------ bot/__main__.py | 1 + 3 files changed, 127 insertions(+), 96 deletions(-) diff --git a/Pipfile b/Pipfile index 33be99587..e25e7b1e1 100644 --- a/Pipfile +++ b/Pipfile @@ -12,7 +12,7 @@ beautifulsoup4 = "~=4.9" colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"} coloredlogs = "~=14.0" deepdiff = "~=4.0" -discord.py = "~=1.3.2" +discord-py = {git = "https://github.com/Rapptz/discord.py.git",ref = "e971e2f16cba22decd25db6b44e9cc84adf08555",editable = true} fakeredis = "~=1.4" feedparser = "~=5.2" fuzzywuzzy = "~=0.17" diff --git a/Pipfile.lock b/Pipfile.lock index 0e591710c..12325f2a7 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "0297accc3d614d3da8080b89d56ef7fe489c28a0ada8102df396a604af7ee330" + "sha256": "f6fac6e59e6579ea4cc0e2b49a5fa59785137d02e6c6a7df47ef502375313703" }, "pipfile-spec": 6, "requires": { @@ -63,6 +63,7 @@ "sha256:41a9d4eb17db805f30ed172f3f609fe0c2b16657fb15b1b67df19d251dd93c0d", "sha256:7c19477a9450824cb79f9949fd238f4148e2c0dca67756a2868863c387209f04" ], + "markers": "python_version >= '3.6'", "version": "==3.2.2" }, "alabaster": { @@ -77,6 +78,7 @@ "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3" ], + "markers": "python_full_version >= '3.5.3'", "version": "==3.0.1" }, "attrs": { @@ -84,6 +86,7 @@ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==19.3.0" }, "babel": { @@ -91,6 +94,7 @@ "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38", "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.8.0" }, "beautifulsoup4": { @@ -104,10 +108,10 @@ }, "certifi": { "hashes": [ - "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304", - "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519" + "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3", + "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41" ], - "version": "==2020.4.5.1" + "version": "==2020.6.20" }, "cffi": { "hashes": [ @@ -154,7 +158,6 @@ "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff", "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1" ], - "index": "pypi", "markers": "sys_platform == 'win32'", "version": "==0.4.3" }, @@ -174,26 +177,17 @@ "index": "pypi", "version": "==4.3.2" }, - "discord": { - "hashes": [ - "sha256:9d4debb4a37845543bd4b92cb195bc53a302797333e768e70344222857ff1559", - "sha256:ff6653655e342e7721dfb3f10421345fd852c2a33f2cca912b1c39b3778a9429" - ], - "index": "pypi", - "version": "==1.0.1" - }, - "discord.py": { - "hashes": [ - "sha256:406871b06d86c3dc49fba63238519f28628dac946fef8a0e22988ff58ec05580", - "sha256:ad00e34c72d2faa8db2157b651d05f3c415d7d05078e7e41dc9e8dc240051beb" - ], - "version": "==1.3.3" + "discord-py": { + "editable": true, + "git": "https://github.com/Rapptz/discord.py.git", + "ref": "e971e2f16cba22decd25db6b44e9cc84adf08555" }, "docutils": { "hashes": [ "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af", "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==0.16" }, "fakeredis": { @@ -264,6 +258,7 @@ "sha256:fa2dc05b87d97acc1c6ae63f3e0f39eae5246565232484b08db6bf2dc1580678", "sha256:fe7d6ce9f6a5fbe24f09d95ea93e9c7271abc4e1565da511e1449b107b4d7848" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.0.1" }, "humanfriendly": { @@ -271,20 +266,23 @@ "sha256:bf52ec91244819c780341a3438d5d7b09f431d3f113a475147ac9b7b167a3d12", "sha256:e78960b31198511f45fd455534ae7645a6207d33e512d2e842c766d15d9c8080" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==8.2" }, "idna": { "hashes": [ - "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", - "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" + "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", + "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" ], - "version": "==2.9" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.10" }, "imagesize": { "hashes": [ "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1", "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.2.0" }, "jinja2": { @@ -292,6 +290,7 @@ "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.11.2" }, "lxml": { @@ -370,15 +369,16 @@ "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.1" }, "more-itertools": { "hashes": [ - "sha256:558bb897a2232f5e4f8e2399089e35aecb746e1f9191b6584a151647e89267be", - "sha256:7818f596b1e87be009031c7653d01acc46ed422e6656b394b0f765ce66ed4982" + "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5", + "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2" ], "index": "pypi", - "version": "==8.3.0" + "version": "==8.4.0" }, "multidict": { "hashes": [ @@ -400,19 +400,22 @@ "sha256:fcfbb44c59af3f8ea984de67ec7c306f618a3ec771c2843804069917a8f2e255", "sha256:feed85993dbdb1dbc29102f50bca65bdc68f2c0c8d352468c25b54874f23c39d" ], + "markers": "python_version >= '3.5'", "version": "==4.7.6" }, "ordered-set": { "hashes": [ - "sha256:a31008c57f9c9776b12eb8841b1f61d1e4d70dfbbe8875ccfa2403c54af3d51b" + "sha256:ba93b2df055bca202116ec44b9bead3df33ea63a7d5827ff8e16738b97f33a95" ], - "version": "==4.0.1" + "markers": "python_version >= '3.5'", + "version": "==4.0.2" }, "packaging": { "hashes": [ "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.4" }, "pamqp": { @@ -461,6 +464,7 @@ "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.20" }, "pygments": { @@ -468,6 +472,7 @@ "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44", "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324" ], + "markers": "python_version >= '3.5'", "version": "==2.6.1" }, "pyparsing": { @@ -475,6 +480,7 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "python-dateutil": { @@ -511,32 +517,34 @@ }, "redis": { "hashes": [ - "sha256:2ef11f489003f151777c064c5dbc6653dfb9f3eade159bcadc524619fddc2242", - "sha256:6d65e84bc58091140081ee9d9c187aab0480097750fac44239307a3bdf0b1251" + "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2", + "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24" ], - "version": "==3.5.2" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==3.5.3" }, "requests": { "hashes": [ - "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", - "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" + "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b", + "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898" ], "index": "pypi", - "version": "==2.23.0" + "version": "==2.24.0" }, "sentry-sdk": { "hashes": [ - "sha256:0e5e947d0f7a969314aa23669a94a9712be5a688ff069ff7b9fc36c66adc160c", - "sha256:799a8bf76b012e3030a881be00e97bc0b922ce35dde699c6537122b751d80e2c" + "sha256:da06bc3641e81ec2c942f87a0676cd9180044fa3d1697524a0005345997542e2", + "sha256:e80d61af85d99a1222c1a3e2a24023618374cd50a99673aa7fa3cf920e7d813b" ], "index": "pypi", - "version": "==0.14.4" + "version": "==0.16.0" }, "six": { "hashes": [ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "snowballstemmer": { @@ -548,16 +556,17 @@ }, "sortedcontainers": { "hashes": [ - "sha256:974e9a32f56b17c1bac2aebd9dcf197f3eb9cd30553c5852a3187ad162e1a03a", - "sha256:d9e96492dd51fae31e60837736b38fe42a187b5404c16606ff7ee7cd582d4c60" + "sha256:4e73a757831fc3ca4de2859c422564239a31d8213d09a2a666e375807034d2ba", + "sha256:c633ebde8580f241f274c1f8994a665c0e54a17724fecd0cae2f079e09c36d3f" ], - "version": "==2.1.0" + "version": "==2.2.2" }, "soupsieve": { "hashes": [ "sha256:1634eea42ab371d3d346309b93df7870a88610f0725d47528be902a0d95ecc55", "sha256:a59dc181727e95d25f781f0eb4fd1825ff45590ec8ff49eadfd7f1a537cc0232" ], + "markers": "python_version >= '3.5'", "version": "==2.0.1" }, "sphinx": { @@ -573,6 +582,7 @@ "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a", "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58" ], + "markers": "python_version >= '3.5'", "version": "==1.0.2" }, "sphinxcontrib-devhelp": { @@ -580,6 +590,7 @@ "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e", "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4" ], + "markers": "python_version >= '3.5'", "version": "==1.0.2" }, "sphinxcontrib-htmlhelp": { @@ -587,6 +598,7 @@ "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f", "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b" ], + "markers": "python_version >= '3.5'", "version": "==1.0.3" }, "sphinxcontrib-jsmath": { @@ -594,6 +606,7 @@ "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" ], + "markers": "python_version >= '3.5'", "version": "==1.0.1" }, "sphinxcontrib-qthelp": { @@ -601,6 +614,7 @@ "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72", "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6" ], + "markers": "python_version >= '3.5'", "version": "==1.0.3" }, "sphinxcontrib-serializinghtml": { @@ -608,6 +622,7 @@ "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc", "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a" ], + "markers": "python_version >= '3.5'", "version": "==1.1.4" }, "statsd": { @@ -623,6 +638,7 @@ "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", "version": "==1.25.9" }, "websockets": { @@ -650,6 +666,7 @@ "sha256:e898a0863421650f0bebac8ba40840fc02258ef4714cb7e1fd76b6a6354bda36", "sha256:f8a7bff6e8664afc4e6c28b983845c5bc14965030e3fb98789734d416af77c4b" ], + "markers": "python_full_version >= '3.6.1'", "version": "==8.1" }, "yarl": { @@ -672,6 +689,7 @@ "sha256:d8cdee92bc930d8b09d8bd2043cedd544d9c8bd7436a77678dd602467a993080", "sha256:e15199cdb423316e15f108f51249e44eb156ae5dba232cb73be555324a1d49c2" ], + "markers": "python_version >= '3.5'", "version": "==1.4.2" } }, @@ -688,6 +706,7 @@ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==19.3.0" }, "cfgv": { @@ -695,50 +714,55 @@ "sha256:1ccf53320421aeeb915275a196e23b3b8ae87dea8ac6698b1638001d4a486d53", "sha256:c8e8f552ffcc6194f4e18dd4f68d9aef0c0d58ae7e7be8c82bee3c5e9edfa513" ], + "markers": "python_full_version >= '3.6.1'", "version": "==3.1.0" }, "coverage": { "hashes": [ - "sha256:00f1d23f4336efc3b311ed0d807feb45098fc86dee1ca13b3d6768cdab187c8a", - "sha256:01333e1bd22c59713ba8a79f088b3955946e293114479bbfc2e37d522be03355", - "sha256:0cb4be7e784dcdc050fc58ef05b71aa8e89b7e6636b99967fadbdba694cf2b65", - "sha256:0e61d9803d5851849c24f78227939c701ced6704f337cad0a91e0972c51c1ee7", - "sha256:1601e480b9b99697a570cea7ef749e88123c04b92d84cedaa01e117436b4a0a9", - "sha256:2742c7515b9eb368718cd091bad1a1b44135cc72468c731302b3d641895b83d1", - "sha256:2d27a3f742c98e5c6b461ee6ef7287400a1956c11421eb574d843d9ec1f772f0", - "sha256:402e1744733df483b93abbf209283898e9f0d67470707e3c7516d84f48524f55", - "sha256:5c542d1e62eece33c306d66fe0a5c4f7f7b3c08fecc46ead86d7916684b36d6c", - "sha256:5f2294dbf7875b991c381e3d5af2bcc3494d836affa52b809c91697449d0eda6", - "sha256:6402bd2fdedabbdb63a316308142597534ea8e1895f4e7d8bf7476c5e8751fef", - "sha256:66460ab1599d3cf894bb6baee8c684788819b71a5dc1e8fa2ecc152e5d752019", - "sha256:782caea581a6e9ff75eccda79287daefd1d2631cc09d642b6ee2d6da21fc0a4e", - "sha256:79a3cfd6346ce6c13145731d39db47b7a7b859c0272f02cdb89a3bdcbae233a0", - "sha256:7a5bdad4edec57b5fb8dae7d3ee58622d626fd3a0be0dfceda162a7035885ecf", - "sha256:8fa0cbc7ecad630e5b0f4f35b0f6ad419246b02bc750de7ac66db92667996d24", - "sha256:a027ef0492ede1e03a8054e3c37b8def89a1e3c471482e9f046906ba4f2aafd2", - "sha256:a3f3654d5734a3ece152636aad89f58afc9213c6520062db3978239db122f03c", - "sha256:a82b92b04a23d3c8a581fc049228bafde988abacba397d57ce95fe95e0338ab4", - "sha256:acf3763ed01af8410fc36afea23707d4ea58ba7e86a8ee915dfb9ceff9ef69d0", - "sha256:adeb4c5b608574a3d647011af36f7586811a2c1197c861aedb548dd2453b41cd", - "sha256:b83835506dfc185a319031cf853fa4bb1b3974b1f913f5bb1a0f3d98bdcded04", - "sha256:bb28a7245de68bf29f6fb199545d072d1036a1917dca17a1e75bbb919e14ee8e", - "sha256:bf9cb9a9fd8891e7efd2d44deb24b86d647394b9705b744ff6f8261e6f29a730", - "sha256:c317eaf5ff46a34305b202e73404f55f7389ef834b8dbf4da09b9b9b37f76dd2", - "sha256:dbe8c6ae7534b5b024296464f387d57c13caa942f6d8e6e0346f27e509f0f768", - "sha256:de807ae933cfb7f0c7d9d981a053772452217df2bf38e7e6267c9cbf9545a796", - "sha256:dead2ddede4c7ba6cb3a721870f5141c97dc7d85a079edb4bd8d88c3ad5b20c7", - "sha256:dec5202bfe6f672d4511086e125db035a52b00f1648d6407cc8e526912c0353a", - "sha256:e1ea316102ea1e1770724db01998d1603ed921c54a86a2efcb03428d5417e489", - "sha256:f90bfc4ad18450c80b024036eaf91e4a246ae287701aaa88eaebebf150868052" - ], - "index": "pypi", - "version": "==5.1" + "sha256:0fc4e0d91350d6f43ef6a61f64a48e917637e1dcfcba4b4b7d543c628ef82c2d", + "sha256:10f2a618a6e75adf64329f828a6a5b40244c1c50f5ef4ce4109e904e69c71bd2", + "sha256:12eaccd86d9a373aea59869bc9cfa0ab6ba8b1477752110cb4c10d165474f703", + "sha256:1874bdc943654ba46d28f179c1846f5710eda3aeb265ff029e0ac2b52daae404", + "sha256:1dcebae667b73fd4aa69237e6afb39abc2f27520f2358590c1b13dd90e32abe7", + "sha256:1e58fca3d9ec1a423f1b7f2aa34af4f733cbfa9020c8fe39ca451b6071237405", + "sha256:214eb2110217f2636a9329bc766507ab71a3a06a8ea30cdeebb47c24dce5972d", + "sha256:25fe74b5b2f1b4abb11e103bb7984daca8f8292683957d0738cd692f6a7cc64c", + "sha256:32ecee61a43be509b91a526819717d5e5650e009a8d5eda8631a59c721d5f3b6", + "sha256:3740b796015b889e46c260ff18b84683fa2e30f0f75a171fb10d2bf9fb91fc70", + "sha256:3b2c34690f613525672697910894b60d15800ac7e779fbd0fccf532486c1ba40", + "sha256:41d88736c42f4a22c494c32cc48a05828236e37c991bd9760f8923415e3169e4", + "sha256:42fa45a29f1059eda4d3c7b509589cc0343cd6bbf083d6118216830cd1a51613", + "sha256:4bb385a747e6ae8a65290b3df60d6c8a692a5599dc66c9fa3520e667886f2e10", + "sha256:509294f3e76d3f26b35083973fbc952e01e1727656d979b11182f273f08aa80b", + "sha256:5c74c5b6045969b07c9fb36b665c9cac84d6c174a809fc1b21bdc06c7836d9a0", + "sha256:60a3d36297b65c7f78329b80120f72947140f45b5c7a017ea730f9112b40f2ec", + "sha256:6f91b4492c5cde83bfe462f5b2b997cdf96a138f7c58b1140f05de5751623cf1", + "sha256:7403675df5e27745571aba1c957c7da2dacb537c21e14007ec3a417bf31f7f3d", + "sha256:87bdc8135b8ee739840eee19b184804e5d57f518578ffc797f5afa2c3c297913", + "sha256:8a3decd12e7934d0254939e2bf434bf04a5890c5bf91a982685021786a08087e", + "sha256:9702e2cb1c6dec01fb8e1a64c015817c0800a6eca287552c47a5ee0ebddccf62", + "sha256:a4d511012beb967a39580ba7d2549edf1e6865a33e5fe51e4dce550522b3ac0e", + "sha256:bbb387811f7a18bdc61a2ea3d102be0c7e239b0db9c83be7bfa50f095db5b92a", + "sha256:bfcc811883699ed49afc58b1ed9f80428a18eb9166422bce3c31a53dba00fd1d", + "sha256:c32aa13cc3fe86b0f744dfe35a7f879ee33ac0a560684fef0f3e1580352b818f", + "sha256:ca63dae130a2e788f2b249200f01d7fa240f24da0596501d387a50e57aa7075e", + "sha256:d54d7ea74cc00482a2410d63bf10aa34ebe1c49ac50779652106c867f9986d6b", + "sha256:d67599521dff98ec8c34cd9652cbcfe16ed076a2209625fca9dc7419b6370e5c", + "sha256:d82db1b9a92cb5c67661ca6616bdca6ff931deceebb98eecbd328812dab52032", + "sha256:d9ad0a988ae20face62520785ec3595a5e64f35a21762a57d115dae0b8fb894a", + "sha256:ebf2431b2d457ae5217f3a1179533c456f3272ded16f8ed0b32961a6d90e38ee", + "sha256:ed9a21502e9223f563e071759f769c3d6a2e1ba5328c31e86830368e8d78bc9c", + "sha256:f50632ef2d749f541ca8e6c07c9928a37f87505ce3a9f20c8446ad310f1aa87b" + ], + "index": "pypi", + "version": "==5.2" }, "distlib": { "hashes": [ - "sha256:2e166e231a26b36d6dfe35a48c4464346620f8645ed0ace01ee31822b288de21" + "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb", + "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1" ], - "version": "==0.3.0" + "version": "==0.3.1" }, "filelock": { "hashes": [ @@ -749,19 +773,19 @@ }, "flake8": { "hashes": [ - "sha256:c69ac1668e434d37a2d2880b3ca9aafd54b3a10a3ac1ab101d22f29e29cf8634", - "sha256:ccaa799ef9893cebe69fdfefed76865aeaefbb94cb8545617b2298786a4de9a5" + "sha256:15e351d19611c887e482fb960eae4d44845013cc142d42896e9862f775d8cf5c", + "sha256:f04b9fcbac03b0a3e58c0ab3a0ecc462e023a9faf046d57794184028123aa208" ], "index": "pypi", - "version": "==3.8.2" + "version": "==3.8.3" }, "flake8-annotations": { "hashes": [ - "sha256:9091d920406a7ff10e401e0dd1baa396d1d7d2e3d101a9beecf815f5894ad554", - "sha256:f59fdceb8c8f380a20aed20e1ba8a57bde05935958166c52be2249f113f7ab75" + "sha256:babc81a17a5f1a63464195917e20d3e8663fb712b3633d4522dbfc407cff31b3", + "sha256:fcd833b415726a7a374922c95a5c47a7a4d8ea71cb4a586369c665e7476146e1" ], "index": "pypi", - "version": "==2.1.0" + "version": "==2.2.0" }, "flake8-bugbear": { "hashes": [ @@ -819,10 +843,11 @@ }, "identify": { "hashes": [ - "sha256:0f3c3aac62b51b86fea6ff52fe8ff9e06f57f10411502443809064d23e16f1c2", - "sha256:f9ad3d41f01e98eb066b6e05c5b184fd1e925fadec48eb165b4e01c72a1ef3a7" + "sha256:c4d07f2b979e3931894170a9e0d4b8281e6905ea6d018c326f7ffefaf20db680", + "sha256:dac33eff90d57164e289fb20bf4e131baef080947ee9bf45efcd0da8d19064bf" ], - "version": "==1.4.16" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.4.21" }, "mccabe": { "hashes": [ @@ -833,31 +858,32 @@ }, "nodeenv": { "hashes": [ - "sha256:5b2438f2e42af54ca968dd1b374d14a1194848955187b0e5e4be1f73813a5212" + "sha256:4b0b77afa3ba9b54f4b6396e60b0c83f59eaeb2d63dc3cc7a70f7f4af96c82bc" ], - "version": "==1.3.5" + "version": "==1.4.0" }, "pep8-naming": { "hashes": [ - "sha256:5d9f1056cb9427ce344e98d1a7f5665710e2f20f748438e308995852cfa24164", - "sha256:f3b4a5f9dd72b991bf7d8e2a341d2e1aa3a884a769b5aaac4f56825c1763bf3a" + "sha256:a1dd47dd243adfe8a83616e27cf03164960b507530f155db94e10b36a6cd6724", + "sha256:f43bfe3eea7e0d73e8b5d07d6407ab47f2476ccaeff6937c84275cd30b016738" ], "index": "pypi", - "version": "==0.10.0" + "version": "==0.11.1" }, "pre-commit": { "hashes": [ - "sha256:5559e09afcac7808933951ffaf4ff9aac524f31efbc3f24d021540b6c579813c", - "sha256:703e2e34cbe0eedb0d319eff9f7b83e2022bb5a3ab5289a6a8841441076514d0" + "sha256:1657663fdd63a321a4a739915d7d03baedd555b25054449090f97bb0cb30a915", + "sha256:e8b1315c585052e729ab7e99dcca5698266bedce9067d21dc909c23e3ceed626" ], "index": "pypi", - "version": "==2.4.0" + "version": "==2.6.0" }, "pycodestyle": { "hashes": [ "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.6.0" }, "pydocstyle": { @@ -865,6 +891,7 @@ "sha256:da7831660b7355307b32778c4a0dbfb137d89254ef31a2b2978f50fc0b4d7586", "sha256:f4f5d210610c2d153fae39093d44224c17429e2ad7da12a8b419aba5c2f614b5" ], + "markers": "python_version >= '3.5'", "version": "==5.0.2" }, "pyflakes": { @@ -872,6 +899,7 @@ "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92", "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.2.0" }, "pyyaml": { @@ -896,6 +924,7 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "snowballstemmer": { @@ -922,10 +951,11 @@ }, "virtualenv": { "hashes": [ - "sha256:a116629d4e7f4d03433b8afa27f43deba09d48bc48f5ecefa4f015a178efb6cf", - "sha256:a730548b27366c5e6cbdf6f97406d861cccece2e22275e8e1a757aeff5e00c70" + "sha256:c11a475400e98450403c0364eb3a2d25d42f71cf1493da64390487b666de4324", + "sha256:e10cc66f40cbda459720dfe1d334c4dc15add0d80f09108224f171006a97a172" ], - "version": "==20.0.21" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==20.0.26" } } } diff --git a/bot/__main__.py b/bot/__main__.py index 4e0d4a111..7e92d1a25 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -29,6 +29,7 @@ bot = Bot( activity=discord.Game(name="Commands: !help"), case_insensitive=True, max_messages=10_000, + allowed_mentions=discord.AllowedMentions(everyone=False, roles=False) ) # Internal/debug -- cgit v1.2.3 From 36330b1e386f1d3964eb34f5c5cc4afdf988358f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 7 Jul 2020 12:15:24 -0700 Subject: Allow owners, admins, and mods roles to be pinged --- bot/__main__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/__main__.py b/bot/__main__.py index 7e92d1a25..37e62c2f1 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -24,12 +24,13 @@ sentry_sdk.init( ] ) +allowed_roles = [discord.Object(id_) for id_ in constants.MODERATION_ROLES] bot = Bot( command_prefix=when_mentioned_or(constants.Bot.prefix), activity=discord.Game(name="Commands: !help"), case_insensitive=True, max_messages=10_000, - allowed_mentions=discord.AllowedMentions(everyone=False, roles=False) + allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) ) # Internal/debug -- cgit v1.2.3 From 8f36817fb4a8c995e92986db3199763b7110aa9e Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Tue, 7 Jul 2020 20:24:04 +0100 Subject: Add git to Docker image --- Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Dockerfile b/Dockerfile index 06a538b2a..c51b9dff6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,9 @@ ENV PIP_NO_CACHE_DIR=false \ PIPENV_HIDE_EMOJIS=1 \ PIPENV_IGNORE_VIRTUALENVS=1 \ PIPENV_NOSPIN=1 + +RUN apt-get update +RUN apt-get install -y git # Install pipenv RUN pip install -U pipenv -- cgit v1.2.3 From baf10b6327ba8ca6f3b2b644613170ee5f937e95 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Tue, 7 Jul 2020 20:28:17 +0100 Subject: Fix git install in Dockerfile --- Dockerfile | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index c51b9dff6..0b1674e7a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,9 +5,11 @@ ENV PIP_NO_CACHE_DIR=false \ PIPENV_HIDE_EMOJIS=1 \ PIPENV_IGNORE_VIRTUALENVS=1 \ PIPENV_NOSPIN=1 - -RUN apt-get update -RUN apt-get install -y git + +RUN apt-get -y update \ + && apt-get install -y \ + git \ + && rm -rf /var/lib/apt/lists/* # Install pipenv RUN pip install -U pipenv -- cgit v1.2.3 From ba1b9081cfbc245b7a8fd8d41f7ab7173b097a31 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 7 Jul 2020 12:35:06 -0700 Subject: Don't install discord.py as editable It may be causing it to not be cached in Azure. --- Pipfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pipfile b/Pipfile index e25e7b1e1..29aa1a08f 100644 --- a/Pipfile +++ b/Pipfile @@ -12,7 +12,7 @@ beautifulsoup4 = "~=4.9" colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"} coloredlogs = "~=14.0" deepdiff = "~=4.0" -discord-py = {git = "https://github.com/Rapptz/discord.py.git",ref = "e971e2f16cba22decd25db6b44e9cc84adf08555",editable = true} +discord-py = {git = "https://github.com/Rapptz/discord.py.git",ref = "e971e2f16cba22decd25db6b44e9cc84adf08555"} fakeredis = "~=1.4" feedparser = "~=5.2" fuzzywuzzy = "~=0.17" -- cgit v1.2.3 From a1a44be2b57e35fbaee8cac024fdd74c218c72b1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 7 Jul 2020 12:42:24 -0700 Subject: Re-lock Pipfile Forgot to do this after removing editable. --- Pipfile.lock | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index 12325f2a7..a522e20d3 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "f6fac6e59e6579ea4cc0e2b49a5fa59785137d02e6c6a7df47ef502375313703" + "sha256": "6404ca2550369b6416801688b4382d22fdba178d9319c4a68bd207d1e5aaeaab" }, "pipfile-spec": 6, "requires": { @@ -178,7 +178,6 @@ "version": "==4.3.2" }, "discord-py": { - "editable": true, "git": "https://github.com/Rapptz/discord.py.git", "ref": "e971e2f16cba22decd25db6b44e9cc84adf08555" }, -- cgit v1.2.3 From a201e76c805fe69e70e39bbd8a24f81ee5d0fe9b Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 8 Jul 2020 21:39:17 +0300 Subject: Help Channels: Simplify unpinning Remove complex None checking message fetching and replace it with `bot.http.unpin_message` and catch exception when message don't exist. --- bot/cogs/help_channels.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index bb97759ee..9313efc67 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -554,19 +554,12 @@ class HelpChannels(Scheduler, commands.Cog): """ msg_id = await self.question_messages.pop(channel.id) - # When message ID exist in cache, try to get it from cache first. When this fail, use API request. - # When this return 404, this mean that message is deleted and can't be unpinned. - if msg_id: - msg = discord.utils.get(self.bot.cached_messages, id=msg_id) - if msg is None: - try: - msg = await channel.fetch_message(msg_id) - except discord.NotFound: - log.debug(f"Can't unpin message {msg_id} because this is deleted.") - - # When we got message, then unpin it - if msg: - await msg.unpin() + try: + await self.bot.http.unpin_message(channel.id, msg_id) + except discord.HTTPException: + log.trace(f"Message {msg_id} don't exist, can't unpin.") + else: + log.trace(f"Unpinned message {msg_id}.") log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") -- cgit v1.2.3 From d6c775bc96d8b913677a87c9025a6194831d4b3b Mon Sep 17 00:00:00 2001 From: swfarnsworth Date: Wed, 8 Jul 2020 15:56:49 -0400 Subject: Initial commit for proposed range-len command --- bot/resources/tags/range-len.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 bot/resources/tags/range-len.md diff --git a/bot/resources/tags/range-len.md b/bot/resources/tags/range-len.md new file mode 100644 index 000000000..b1c973647 --- /dev/null +++ b/bot/resources/tags/range-len.md @@ -0,0 +1,19 @@ +Iterating over `range(len(...))` is a common approach to accessing each item +in an ordered collection. + +```py +for i in range(len(my_list)): + do_something(my_list[i]) +``` + +The pythonic syntax is much simpler, and is +guaranteed to produce elements in the same order: + +```py +for item in my_list: + do_something(item) +``` + +Python has other solutions for cases when the index itself might be needed. +To get the element at the same index from two or more lists, use [zip](https://docs.python.org/3/library/functions.html#zip). +To get both the index and the element at that index, use [enumerate](https://docs.python.org/3/library/functions.html#enumerate). -- cgit v1.2.3 From 4775b174597e72100641b97ea6ef2c9e63622d60 Mon Sep 17 00:00:00 2001 From: Slushie Date: Wed, 8 Jul 2020 21:28:17 +0100 Subject: Edit BadArgument error message --- 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 5de961116..a7f8074e2 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -170,7 +170,7 @@ class ErrorHandler(Cog): await prepared_help_command self.bot.stats.incr("errors.too_many_arguments") elif isinstance(e, errors.BadArgument): - await ctx.send(f"Bad argument: {e}\n") + await ctx.send("Bad argument: Please double check your input arguments and try again.\n") await prepared_help_command self.bot.stats.incr("errors.bad_argument") elif isinstance(e, errors.BadUnionArgument): -- cgit v1.2.3 From 9060c909f6816eb2fff97a41d709a1c67b034af1 Mon Sep 17 00:00:00 2001 From: Slushie Date: Wed, 8 Jul 2020 21:29:14 +0100 Subject: Create a filtering function to filter eval results --- bot/cogs/filtering.py | 172 +++++++++++++++++++++++++++++++++++--------------- 1 file changed, 120 insertions(+), 52 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 76ea68660..ae77ad7f0 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -2,7 +2,7 @@ import asyncio import logging import re from datetime import datetime, timedelta -from typing import List, Mapping, Optional, Union +from typing import List, Mapping, Optional, Tuple, Union import dateutil import discord.errors @@ -200,24 +200,66 @@ class Filtering(Cog, Scheduler): # Update time when alert sent await self.name_alerts.set(member.id, datetime.utcnow().timestamp()) + async def _filter_eval(self, result: str, msg: Message) -> bool: + """ + Filter the result of an !eval to see if it violates any of our rules, and then respond accordingly. + + Also requires the original message, to check whether to filter and for mod logs. + Returns whether a filter was triggered or not. + """ + # Should we filter this message? + if self._check_filter(msg): + for filter_name, _filter in self.filters.items(): + # Is this specific filter enabled in the config? + # We also do not need to worry about filters that take the full message, + # since all we have is an arbitrary string. + if _filter["enabled"] and _filter["content_only"]: + match = await _filter["function"](result) + + if match: + # If this is a filter (not a watchlist), we set the variable so we know + # that it has been triggered + if _filter["type"] == "filter": + filter_triggered = True + + # We do not have to check against DM channels since !eval cannot be used there. + channel_str = f"in {msg.channel.mention}" + + message_content, additional_embeds, additional_embeds_msg = self._add_stats( + filter_name, match, result + ) + + message = ( + f"The {filter_name} {_filter['type']} was triggered " + f"by **{msg.author}** " + f"(`{msg.author.id}`) {channel_str} using !eval with " + f"[the following message]({msg.jump_url}):\n\n" + f"{message_content}" + ) + + log.debug(message) + + # Send pretty mod log embed to mod-alerts + await self.mod_log.send_log_message( + icon_url=Icons.filtering, + colour=Colour(Colours.soft_red), + title=f"{_filter['type'].title()} triggered!", + text=message, + thumbnail=msg.author.avatar_url_as(static_format="png"), + channel_id=Channels.mod_alerts, + ping_everyone=Filter.ping_everyone, + additional_embeds=additional_embeds, + additional_embeds_msg=additional_embeds_msg + ) + + break # We don't want multiple filters to trigger + + return filter_triggered + async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> None: """Filter the input message to see if it violates any of our rules, and then respond accordingly.""" # Should we filter this message? - role_whitelisted = False - - if type(msg.author) is Member: # Only Member has roles, not User. - for role in msg.author.roles: - if role.id in Filter.role_whitelist: - role_whitelisted = True - - filter_message = ( - msg.channel.id not in Filter.channel_whitelist # Channel not in whitelist - and not role_whitelisted # Role not in whitelist - and not msg.author.bot # Author not a bot - ) - - # If none of the above, we can start filtering. - if filter_message: + if self._check_filter(msg): for filter_name, _filter in self.filters.items(): # Is this specific filter enabled in the config? if _filter["enabled"]: @@ -276,16 +318,9 @@ class Filtering(Cog, Scheduler): else: channel_str = f"in {msg.channel.mention}" - # Word and match stats for watch_regex - if filter_name == "watch_regex": - surroundings = match.string[max(match.start() - 10, 0): match.end() + 10] - message_content = ( - f"**Match:** '{match[0]}'\n" - f"**Location:** '...{escape_markdown(surroundings)}...'\n" - f"\n**Original Message:**\n{escape_markdown(msg.content)}" - ) - else: # Use content of discord Message - message_content = msg.content + message_content, additional_embeds, additional_embeds_msg = self._add_stats( + filter_name, match, msg.content + ) message = ( f"The {filter_name} {_filter['type']} was triggered " @@ -297,30 +332,6 @@ class Filtering(Cog, Scheduler): log.debug(message) - self.bot.stats.incr(f"filters.{filter_name}") - - additional_embeds = None - additional_embeds_msg = None - - # The function returns True for invalid invites. - # They have no data so additional embeds can't be created for them. - if filter_name == "filter_invites" and match is not True: - additional_embeds = [] - for invite, data in match.items(): - embed = discord.Embed(description=( - f"**Members:**\n{data['members']}\n" - f"**Active:**\n{data['active']}" - )) - embed.set_author(name=data["name"]) - embed.set_thumbnail(url=data["icon"]) - embed.set_footer(text=f"Guild Invite Code: {invite}") - additional_embeds.append(embed) - additional_embeds_msg = "For the following guild(s):" - - elif filter_name == "watch_rich_embeds": - additional_embeds = msg.embeds - additional_embeds_msg = "With the following embed(s):" - # Send pretty mod log embed to mod-alerts await self.mod_log.send_log_message( icon_url=Icons.filtering, @@ -336,6 +347,63 @@ class Filtering(Cog, Scheduler): break # We don't want multiple filters to trigger + def _add_stats(self, name: str, match: Union[re.Match, dict, bool, List[discord.Embed]], content: str) -> Tuple[ + str, Optional[List[discord.Embed]], Optional[str] + ]: + """Adds relevant statistical information to the relevant filter and increments the bot's stats.""" + # Word and match stats for watch_regex + if name == "watch_regex": + surroundings = match.string[max(match.start() - 10, 0): match.end() + 10] + message_content = ( + f"**Match:** '{match[0]}'\n" + f"**Location:** '...{escape_markdown(surroundings)}...'\n" + f"\n**Original Message:**\n{escape_markdown(content)}" + ) + else: # Use original content + message_content = content + + additional_embeds = None + additional_embeds_msg = None + + self.bot.stats.incr(f"filters.{name}") + + # The function returns True for invalid invites. + # They have no data so additional embeds can't be created for them. + if name == "filter_invites" and match is not True: + additional_embeds = [] + for invite, data in match.items(): + embed = discord.Embed(description=( + f"**Members:**\n{data['members']}\n" + f"**Active:**\n{data['active']}" + )) + embed.set_author(name=data["name"]) + embed.set_thumbnail(url=data["icon"]) + embed.set_footer(text=f"Guild Invite Code: {invite}") + additional_embeds.append(embed) + additional_embeds_msg = "For the following guild(s):" + + elif name == "watch_rich_embeds": + additional_embeds = match + additional_embeds_msg = "With the following embed(s):" + + return message_content, additional_embeds, additional_embeds_msg + + @staticmethod + def _check_filter(msg: Message) -> bool: + """Check whitelists to see if we should filter this message.""" + role_whitelisted = False + + if type(msg.author) is Member: # Only Member has roles, not User. + for role in msg.author.roles: + if role.id in Filter.role_whitelist: + role_whitelisted = True + + return ( + msg.channel.id not in Filter.channel_whitelist # Channel not in whitelist + and not role_whitelisted # Role not in whitelist + and not msg.author.bot # Author not a bot + ) + @staticmethod async def _has_watch_regex_match(text: str) -> Union[bool, re.Match]: """ @@ -428,7 +496,7 @@ class Filtering(Cog, Scheduler): return invite_data if invite_data else False @staticmethod - async def _has_rich_embed(msg: Message) -> bool: + async def _has_rich_embed(msg: Message) -> Union[bool, List[discord.Embed]]: """Determines if `msg` contains any rich embeds not auto-generated from a URL.""" if msg.embeds: for embed in msg.embeds: @@ -437,7 +505,7 @@ class Filtering(Cog, Scheduler): if not embed.url or embed.url not in urls: # If `embed.url` does not exist or if `embed.url` is not part of the content # of the message, it's unlikely to be an auto-generated embed by Discord. - return True + return msg.embeds else: log.trace( "Found a rich embed sent by a regular user account, " -- cgit v1.2.3 From 63846d17a851c97fe073e5c1e27cd65719d2c854 Mon Sep 17 00:00:00 2001 From: Slushie Date: Wed, 8 Jul 2020 21:35:04 +0100 Subject: Call the filter eval command after receiving an eval result --- bot/cogs/snekbox.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index a2a7574d4..649bab492 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -212,7 +212,12 @@ class Snekbox(Cog): else: self.bot.stats.incr("snekbox.python.success") - response = await ctx.send(msg) + filter_cog = self.bot.get_cog("Filtering") + filter_triggered = await filter_cog._filter_eval(msg, ctx.message) + if filter_triggered: + response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.") + else: + response = await ctx.send(msg) self.bot.loop.create_task( wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot) ) -- cgit v1.2.3 From 9174125a41793d4703a81dc6783f4244f1634d27 Mon Sep 17 00:00:00 2001 From: swfarnsworth Date: Wed, 8 Jul 2020 17:51:04 -0400 Subject: Removed hard line breaks --- bot/resources/tags/range-len.md | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/bot/resources/tags/range-len.md b/bot/resources/tags/range-len.md index b1c973647..9b88aab47 100644 --- a/bot/resources/tags/range-len.md +++ b/bot/resources/tags/range-len.md @@ -1,19 +1,15 @@ -Iterating over `range(len(...))` is a common approach to accessing each item -in an ordered collection. +Iterating over `range(len(...))` is a common approach to accessing each item in an ordered collection. ```py for i in range(len(my_list)): do_something(my_list[i]) ``` -The pythonic syntax is much simpler, and is -guaranteed to produce elements in the same order: +The pythonic syntax is much simpler, and is guaranteed to produce elements in the same order: ```py for item in my_list: do_something(item) ``` -Python has other solutions for cases when the index itself might be needed. -To get the element at the same index from two or more lists, use [zip](https://docs.python.org/3/library/functions.html#zip). -To get both the index and the element at that index, use [enumerate](https://docs.python.org/3/library/functions.html#enumerate). +Python has other solutions for cases when the index itself might be needed. To get the element at the same index from two or more lists, use [zip](https://docs.python.org/3/library/functions.html#zip). To get both the index and the element at that index, use [enumerate](https://docs.python.org/3/library/functions.html#enumerate). -- cgit v1.2.3 From 918e1b9ca628abd7867812b32096d05dcf69f32f Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 9 Jul 2020 12:07:30 +0200 Subject: Incidents: use `moderation_roles` constant Better than building the set manually. Tested against regression by comparing the two sets for equality. Suggested by vivax. Co-authored-by: vivax3794 <51753506+vivax3794@users.noreply.github.com> --- bot/cogs/moderation/incidents.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 1a12c8bbd..be46c8202 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -8,7 +8,7 @@ import discord from discord.ext.commands import Cog from bot.bot import Bot -from bot.constants import Channels, Colours, Emojis, Roles, Webhooks +from bot.constants import Channels, Colours, Emojis, Guild, Webhooks from bot.utils.messages import sub_clyde log = logging.getLogger(__name__) @@ -35,8 +35,8 @@ class Signal(Enum): INVESTIGATING = Emojis.incident_investigating -# Reactions from roles not listed here will be removed -ALLOWED_ROLES: t.Set[int] = {Roles.moderators, Roles.admins, Roles.owners} +# Reactions from non-mod roles will be removed +ALLOWED_ROLES: t.Set[int] = set(Guild.moderation_roles) # Message must have all of these emoji to pass the `has_signals` check ALL_SIGNALS: t.Set[str] = {signal.value for signal in Signal} -- cgit v1.2.3 From ddb1f556ace346a97b8639f278fae8915078e78d Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 9 Jul 2020 12:11:16 +0200 Subject: Incidents tests: improve in-line comment wording Co-authored-by: MarkKoz --- tests/bot/cogs/moderation/test_incidents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index f8d479cef..789a37cd4 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -318,7 +318,7 @@ class TestArchive(TestIncidents): webhook = MockAsyncWebhook() self.cog_instance.bot.fetch_webhook = AsyncMock(return_value=webhook) # Patch in our webhook - # Define our own `incident` for archivation + # Define our own `incident` to be archived incident = MockMessage( content="this is an incident", author=MockUser(name="author_name", avatar_url="author_avatar"), -- cgit v1.2.3 From d10a61d3ef21cbf511304a97a5e2871bd1fcb2dd Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 9 Jul 2020 12:18:21 +0200 Subject: Config: refactor #incidents constants to lexicographical sorting Co-authored-by: MarkKoz --- bot/constants.py | 6 +++--- config-default.yml | 18 +++++++++--------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index b3ef1660f..cd660acee 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -427,12 +427,12 @@ class Webhooks(metaclass=YAMLGetter): section = "guild" subsection = "webhooks" - talent_pool: int big_brother: int - reddit: int - duck_pond: int dev_log: int + duck_pond: int incidents_archive: int + reddit: int + talent_pool: int class Roles(metaclass=YAMLGetter): diff --git a/config-default.yml b/config-default.yml index 4c0196dc5..7fcc27d64 100644 --- a/config-default.yml +++ b/config-default.yml @@ -171,13 +171,13 @@ guild: admin_spam: &ADMIN_SPAM 563594791770914816 defcon: &DEFCON 464469101889454091 helpers: &HELPERS 385474242440986624 + incidents: 714214212200562749 + incidents_archive: 720668923636351037 mods: &MODS 305126844661760000 mod_alerts: &MOD_ALERTS 473092532147060736 mod_spam: &MOD_SPAM 620607373828030464 organisation: &ORGANISATION 551789653284356126 staff_lounge: &STAFF_LOUNGE 464905259261755392 - incidents: 714214212200562749 - incidents_archive: 720668923636351037 # Voice admins_voice: &ADMINS_VOICE 500734494840717332 @@ -250,13 +250,13 @@ guild: - *HELPERS_ROLE webhooks: - talent_pool: 569145364800602132 - big_brother: 569133704568373283 - reddit: 635408384794951680 - duck_pond: 637821475327311927 - dev_log: 680501655111729222 - python_news: &PYNEWS_WEBHOOK 704381182279942324 - incidents_archive: 720671599790915702 + big_brother: 569133704568373283 + dev_log: 680501655111729222 + duck_pond: 637821475327311927 + incidents_archive: 720671599790915702 + python_news: &PYNEWS_WEBHOOK 704381182279942324 + reddit: 635408384794951680 + talent_pool: 569145364800602132 filter: -- cgit v1.2.3 From 5f73f40eeb025e6694443a8bc4535df894b83e4f Mon Sep 17 00:00:00 2001 From: slushiegoose <38522108+slushiegoose@users.noreply.github.com> Date: Thu, 9 Jul 2020 15:29:03 +0100 Subject: Fix missing hypen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Leon Sandøy --- 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 a7f8074e2..233851e41 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -170,7 +170,7 @@ class ErrorHandler(Cog): await prepared_help_command self.bot.stats.incr("errors.too_many_arguments") elif isinstance(e, errors.BadArgument): - await ctx.send("Bad argument: Please double check your input arguments and try again.\n") + await ctx.send("Bad argument: Please double-check your input arguments and try again.\n") await prepared_help_command self.bot.stats.incr("errors.bad_argument") elif isinstance(e, errors.BadUnionArgument): -- cgit v1.2.3 From 288c6526e03388bf7ff5b3b1e8b861ad1a7f6e63 Mon Sep 17 00:00:00 2001 From: Slushie Date: Thu, 9 Jul 2020 15:32:27 +0100 Subject: Add missing variable assignment to stop NameErrors occurring --- bot/cogs/filtering.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index ae77ad7f0..4c97073c3 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -207,6 +207,7 @@ class Filtering(Cog, Scheduler): Also requires the original message, to check whether to filter and for mod logs. Returns whether a filter was triggered or not. """ + filter_triggered = False # Should we filter this message? if self._check_filter(msg): for filter_name, _filter in self.filters.items(): -- cgit v1.2.3 From 8cba21c353a728d2c09ad82a425c46ce3f03abf0 Mon Sep 17 00:00:00 2001 From: Steele Farnsworth <32915757+swfarnsworth@users.noreply.github.com> Date: Thu, 9 Jul 2020 11:20:01 -0400 Subject: Update range-len.md Removed all blank lines to improve how it's rendered on Discord; thanks @kwzrd for rendering this! --- bot/resources/tags/range-len.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/bot/resources/tags/range-len.md b/bot/resources/tags/range-len.md index 9b88aab47..65665eccf 100644 --- a/bot/resources/tags/range-len.md +++ b/bot/resources/tags/range-len.md @@ -1,15 +1,11 @@ Iterating over `range(len(...))` is a common approach to accessing each item in an ordered collection. - ```py for i in range(len(my_list)): do_something(my_list[i]) ``` - The pythonic syntax is much simpler, and is guaranteed to produce elements in the same order: - ```py for item in my_list: do_something(item) ``` - Python has other solutions for cases when the index itself might be needed. To get the element at the same index from two or more lists, use [zip](https://docs.python.org/3/library/functions.html#zip). To get both the index and the element at that index, use [enumerate](https://docs.python.org/3/library/functions.html#enumerate). -- cgit v1.2.3 From 39651d0410ed292a5f761d9595ba79833dfa167c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 9 Jul 2020 10:40:47 -0700 Subject: Update discord.py to fix issue with overwrites Fixes BOT-6T --- Pipfile | 2 +- Pipfile.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Pipfile b/Pipfile index 29aa1a08f..2d6b45aa9 100644 --- a/Pipfile +++ b/Pipfile @@ -12,7 +12,7 @@ beautifulsoup4 = "~=4.9" colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"} coloredlogs = "~=14.0" deepdiff = "~=4.0" -discord-py = {git = "https://github.com/Rapptz/discord.py.git",ref = "e971e2f16cba22decd25db6b44e9cc84adf08555"} +discord-py = {git = "https://github.com/Rapptz/discord.py.git",ref = "0bc15fa130b8f01fe2d67446a2184d474b0d0ba7"} fakeredis = "~=1.4" feedparser = "~=5.2" fuzzywuzzy = "~=0.17" diff --git a/Pipfile.lock b/Pipfile.lock index a522e20d3..4b9d092d4 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "6404ca2550369b6416801688b4382d22fdba178d9319c4a68bd207d1e5aaeaab" + "sha256": "8a53baefbbd2a0f3fbaf831f028b23d257a5e28b5efa1260661d74604f4113b8" }, "pipfile-spec": 6, "requires": { @@ -179,7 +179,7 @@ }, "discord-py": { "git": "https://github.com/Rapptz/discord.py.git", - "ref": "e971e2f16cba22decd25db6b44e9cc84adf08555" + "ref": "0bc15fa130b8f01fe2d67446a2184d474b0d0ba7" }, "docutils": { "hashes": [ -- cgit v1.2.3 From de924691e4967b85424fe6e802d7f92846bb0850 Mon Sep 17 00:00:00 2001 From: Dennis Pham Date: Sat, 11 Jul 2020 21:46:12 -0400 Subject: Fix comment --- bot/cogs/help_channels.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 9313efc67..b06934eff 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -701,7 +701,7 @@ class HelpChannels(Scheduler, commands.Cog): log.info(f"Channel #{channel} was claimed by `{message.author.id}`.") await self.move_to_in_use(channel) await self.revoke_send_permissions(message.author) - # Pin message for better access and storage this to cache + # Pin message for better access and store this to cache try: await message.pin() except discord.NotFound: -- cgit v1.2.3 From 210c0a09b1bced80d03ed9ac81845f5f94c8b687 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 12 Jul 2020 13:23:22 +0200 Subject: Ping @Moderators in ModLog Instead of pinging @everyone, let's just ping the people who actually need to see the mod alerts or the modlogs, which would be the mods. `@everyone` is currently not permitted by our allowed_mentions setting, so this also restores pings to those channels. GitHub #1038 https://github.com/python-discord/bot/issues/1038 --- bot/cogs/antispam.py | 4 ++-- bot/cogs/filtering.py | 2 +- bot/cogs/moderation/modlog.py | 10 +++++----- bot/cogs/watchchannels/watchchannel.py | 4 ++-- bot/constants.py | 4 ++-- config-default.yml | 4 ++-- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 0bcca578d..71382bba9 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -98,7 +98,7 @@ class DeletionContext: text=mod_alert_message, thumbnail=last_message.author.avatar_url_as(static_format="png"), channel_id=Channels.mod_alerts, - ping_everyone=AntiSpamConfig.ping_everyone + ping_moderators=AntiSpamConfig.ping_moderators ) @@ -132,7 +132,7 @@ class AntiSpam(Cog): await self.mod_log.send_log_message( title="Error: AntiSpam configuration validation failed!", text=body, - ping_everyone=True, + ping_moderators=True, icon_url=Icons.token_removed, colour=Colour.red() ) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 76ea68660..a5d59085f 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -329,7 +329,7 @@ class Filtering(Cog, Scheduler): text=message, thumbnail=msg.author.avatar_url_as(static_format="png"), channel_id=Channels.mod_alerts, - ping_everyone=Filter.ping_everyone, + ping_moderators=Filter.ping_moderators, additional_embeds=additional_embeds, additional_embeds_msg=additional_embeds_msg ) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index ffbb87bbe..a37a9faf5 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -15,7 +15,7 @@ from discord.ext.commands import Cog, Context from discord.utils import escape_markdown from bot.bot import Bot -from bot.constants import 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.time import humanize_delta log = logging.getLogger(__name__) @@ -88,7 +88,7 @@ class ModLog(Cog, name="ModLog"): text: str, thumbnail: t.Optional[t.Union[str, discord.Asset]] = None, channel_id: int = Channels.mod_log, - ping_everyone: bool = False, + ping_moderators: bool = False, files: t.Optional[t.List[discord.File]] = None, content: t.Optional[str] = None, additional_embeds: t.Optional[t.List[discord.Embed]] = None, @@ -114,11 +114,11 @@ class ModLog(Cog, name="ModLog"): if thumbnail: embed.set_thumbnail(url=thumbnail) - if ping_everyone: + if ping_moderators: if content: - content = f"@everyone\n{content}" + content = f"<@&{Roles.moderators}>\n{content}" else: - content = "@everyone" + content = f"<@&{Roles.moderators}>" channel = self.bot.get_channel(channel_id) log_message = await channel.send(content=content, embed=embed, files=files) diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 7c58a0fb5..8c4af4581 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -120,7 +120,7 @@ class WatchChannel(metaclass=CogABCMeta): await self.modlog.send_log_message( title=f"Error: Failed to initialize the {self.__class__.__name__} watch channel", text=message, - ping_everyone=True, + ping_moderators=True, icon_url=Icons.token_removed, colour=Color.red() ) @@ -132,7 +132,7 @@ class WatchChannel(metaclass=CogABCMeta): await self.modlog.send_log_message( title=f"Warning: Failed to retrieve user cache for the {self.__class__.__name__} watch channel", text="Could not retrieve the list of watched users from the API and messages will not be relayed.", - ping_everyone=True, + ping_moderators=True, icon_url=Icons.token_removed, colour=Color.red() ) diff --git a/bot/constants.py b/bot/constants.py index a1b392c82..34b312d2d 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -225,7 +225,7 @@ class Filter(metaclass=YAMLGetter): notify_user_invites: bool notify_user_domains: bool - ping_everyone: bool + ping_moderators: bool offensive_msg_delete_days: int guild_invite_whitelist: List[int] domain_blacklist: List[str] @@ -522,7 +522,7 @@ class AntiSpam(metaclass=YAMLGetter): section = 'anti_spam' clean_offending: bool - ping_everyone: bool + ping_moderators: bool punishment: Dict[str, Dict[str, int]] rules: Dict[str, Dict[str, int]] diff --git a/config-default.yml b/config-default.yml index 64c4e715b..5dd96d67a 100644 --- a/config-default.yml +++ b/config-default.yml @@ -269,7 +269,7 @@ filter: notify_user_domains: false # Filter configuration - ping_everyone: true # Ping @everyone when we send a mod-alert? + ping_moderators: true # Ping @everyone when we send a mod-alert? offensive_msg_delete_days: 7 # How many days before deleting an offensive message? guild_invite_whitelist: @@ -428,7 +428,7 @@ urls: anti_spam: # Clean messages that violate a rule. clean_offending: true - ping_everyone: true + ping_moderators: true punishment: role_id: *MUTED_ROLE -- cgit v1.2.3 From 57e210ccfcc91132182029f1d931118e715439b2 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 12 Jul 2020 13:38:02 +0200 Subject: Allow role pings in Syncers and help_channels.py Now that we're running Discord 1.4.0a, we need to explicitely allow all the role mentions for sends that don't use ping one of the globally whitelisted role pings, which are Moderators, Admins and Owners. We were pinging roles other than Mods+ in exactly two cases: - Inside the Syncers, whenever we ask for sync confirmation (if the number of roles or users to sync is unusually high) - In the help_channels.py system, whenever we max out help channels and are unable to create more. This commit addresses both of these. GitHub #1038 https://github.com/python-discord/bot/issues/1038 --- bot/cogs/help_channels.py | 4 +++- bot/cogs/sync/syncers.py | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 187adfe51..fd1a449c1 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -624,11 +624,13 @@ class HelpChannels(Scheduler, commands.Cog): channel = self.bot.get_channel(constants.HelpChannels.notify_channel) mentions = " ".join(f"<@&{role}>" for role in constants.HelpChannels.notify_roles) + allowed_roles = [discord.Object(id_) for id_ in constants.HelpChannels.notify_roles] message = await channel.send( f"{mentions} A new available help channel is needed but there " f"are no more dormant ones. Consider freeing up some in-use channels manually by " - f"using the `{constants.Bot.prefix}dormant` command within the channels." + f"using the `{constants.Bot.prefix}dormant` command within the channels.", + allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) ) self.bot.stats.incr("help.out_of_channel_alerts") diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 536455668..f7ba811bc 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -5,6 +5,7 @@ import typing as t from collections import namedtuple from functools import partial +import discord from discord import Guild, HTTPException, Member, Message, Reaction, User from discord.ext.commands import Context @@ -68,7 +69,11 @@ class Syncer(abc.ABC): ) return None - message = await channel.send(f"{self._CORE_DEV_MENTION}{msg_content}") + allowed_roles = [discord.Object(constants.Roles.core_developers)] + message = await channel.send( + f"{self._CORE_DEV_MENTION}{msg_content}", + allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) + ) else: await message.edit(content=msg_content) -- cgit v1.2.3 From cb5e361d04cd9c430bca4fb3496284e469d35c98 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 12 Jul 2020 14:40:26 +0200 Subject: Add the #dm_log ID to constants. https://github.com/python-discord/bot/issues/667 --- bot/constants.py | 1 + config-default.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index a1b392c82..074699025 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -416,6 +416,7 @@ class Channels(metaclass=YAMLGetter): user_log: int verification: int voice_log: int + dm_log: int class Webhooks(metaclass=YAMLGetter): diff --git a/config-default.yml b/config-default.yml index 64c4e715b..d3ba45f88 100644 --- a/config-default.yml +++ b/config-default.yml @@ -150,6 +150,7 @@ guild: mod_log: &MOD_LOG 282638479504965634 user_log: 528976905546760203 voice_log: 640292421988646961 + dm_log: 653713721625018428 # Off-topic off_topic_0: 291284109232308226 -- cgit v1.2.3 From 9042325f06523e04a2c51b39fd20436cd6eaa3fc Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 12 Jul 2020 14:57:15 +0200 Subject: Refactor Duck Pond embed sender to be a util. https://github.com/python-discord/bot/issues/667 --- bot/cogs/duck_pond.py | 30 ++++++++---------------------- bot/utils/webhooks.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 22 deletions(-) create mode 100644 bot/utils/webhooks.py diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index 5b6a7fd62..89b4ad0e4 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -1,5 +1,5 @@ import logging -from typing import Optional, Union +from typing import Union import discord from discord import Color, Embed, Member, Message, RawReactionActionEvent, User, errors @@ -7,7 +7,8 @@ from discord.ext.commands import Cog from bot import constants from bot.bot import Bot -from bot.utils.messages import send_attachments, sub_clyde +from bot.utils.messages import send_attachments +from bot.utils.webhooks import send_webhook log = logging.getLogger(__name__) @@ -18,6 +19,7 @@ class DuckPond(Cog): def __init__(self, bot: Bot): self.bot = bot self.webhook_id = constants.Webhooks.duck_pond + self.webhook = None self.bot.loop.create_task(self.fetch_webhook()) async def fetch_webhook(self) -> None: @@ -47,24 +49,6 @@ class DuckPond(Cog): return True return False - async def send_webhook( - self, - content: Optional[str] = None, - username: Optional[str] = None, - avatar_url: Optional[str] = None, - embed: Optional[Embed] = None, - ) -> None: - """Send a webhook to the duck_pond channel.""" - try: - await self.webhook.send( - content=content, - username=sub_clyde(username), - avatar_url=avatar_url, - embed=embed - ) - except discord.HTTPException: - log.exception("Failed to send a message to the Duck Pool webhook") - async def count_ducks(self, message: Message) -> int: """ Count the number of ducks in the reactions of a specific message. @@ -97,7 +81,8 @@ class DuckPond(Cog): clean_content = message.clean_content if clean_content: - await self.send_webhook( + await send_webhook( + webhook=self.webhook, content=message.clean_content, username=message.author.display_name, avatar_url=message.author.avatar_url @@ -111,7 +96,8 @@ class DuckPond(Cog): description=":x: **This message contained an attachment, but it could not be retrieved**", color=Color.red() ) - await self.send_webhook( + await send_webhook( + webhook=self.webhook, embed=e, username=message.author.display_name, avatar_url=message.author.avatar_url diff --git a/bot/utils/webhooks.py b/bot/utils/webhooks.py new file mode 100644 index 000000000..37fdfe907 --- /dev/null +++ b/bot/utils/webhooks.py @@ -0,0 +1,34 @@ +import logging +from typing import Optional + +import discord +from discord import Embed + +from bot.utils.messages import sub_clyde + +log = logging.getLogger(__name__) + + +async def send_webhook( + webhook: discord.Webhook, + content: Optional[str] = None, + username: Optional[str] = None, + avatar_url: Optional[str] = None, + embed: Optional[Embed] = None, + wait: Optional[bool] = False +) -> None: + """ + Send a message using the provided webhook. + + This uses sub_clyde() and tries for an HTTPException to ensure it doesn't crash. + """ + try: + await webhook.send( + content=content, + username=sub_clyde(username), + avatar_url=avatar_url, + embed=embed, + wait=wait, + ) + except discord.HTTPException: + log.exception("Failed to send a message to the webhook!") -- cgit v1.2.3 From 3fd89d59081f2c906fa43265471d235f4f5b4749 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 12 Jul 2020 15:08:54 +0200 Subject: Remove pointless comment This comment violates the DRY principle. Co-authored-by: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index 5dd96d67a..0f6a25ef2 100644 --- a/config-default.yml +++ b/config-default.yml @@ -269,7 +269,7 @@ filter: notify_user_domains: false # Filter configuration - ping_moderators: true # Ping @everyone when we send a mod-alert? + ping_moderators: true offensive_msg_delete_days: 7 # How many days before deleting an offensive message? guild_invite_whitelist: -- cgit v1.2.3 From ef65033eaed01a2459561dd9fe37133b595f3d3a Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 12 Jul 2020 15:10:00 +0200 Subject: Refactor python_news.py to use webhook util. https://github.com/python-discord/bot/issues/667 --- bot/cogs/python_news.py | 70 ++++++++++++++++++++----------------------------- bot/utils/webhooks.py | 4 +-- 2 files changed, 31 insertions(+), 43 deletions(-) diff --git a/bot/cogs/python_news.py b/bot/cogs/python_news.py index adefd5c7c..1d8f2aeb0 100644 --- a/bot/cogs/python_news.py +++ b/bot/cogs/python_news.py @@ -10,7 +10,7 @@ from discord.ext.tasks import loop from bot import constants from bot.bot import Bot -from bot.utils.messages import sub_clyde +from bot.utils.webhooks import send_webhook PEPS_RSS_URL = "https://www.python.org/dev/peps/peps.rss/" @@ -100,13 +100,20 @@ class PythonNews(Cog): ): continue - msg = await self.send_webhook( + # Build an embed and send a webhook + embed = discord.Embed( title=new["title"], description=new["summary"], timestamp=new_datetime, url=new["link"], - webhook_profile_name=data["feed"]["title"], - footer=data["feed"]["title"] + colour=constants.Colours.soft_green + ) + embed.set_footer(text=data["feed"]["title"], icon_url=AVATAR_URL) + msg = await send_webhook( + webhook=self.webhook, + username=data["feed"]["title"], + embed=embed, + wait=True, ) payload["data"]["pep"].append(pep_nr) @@ -161,15 +168,28 @@ class PythonNews(Cog): content = email_information["content"] link = THREAD_URL.format(id=thread["href"].split("/")[-2], list=maillist) - msg = await self.send_webhook( + + # Build an embed and send a message to the webhook + embed = discord.Embed( title=thread_information["subject"], description=content[:500] + f"... [continue reading]({link})" if len(content) > 500 else content, timestamp=new_date, url=link, - author=f"{email_information['sender_name']} ({email_information['sender']['address']})", - author_url=MAILMAN_PROFILE_URL.format(id=email_information["sender"]["mailman_id"]), - webhook_profile_name=self.webhook_names[maillist], - footer=f"Posted to {self.webhook_names[maillist]}" + colour=constants.Colours.soft_green + ) + embed.set_author( + name=f"{email_information['sender_name']} ({email_information['sender']['address']})", + url=MAILMAN_PROFILE_URL.format(id=email_information["sender"]["mailman_id"]), + ) + embed.set_footer( + text=f"Posted to {self.webhook_names[maillist]}", + icon_url=AVATAR_URL, + ) + msg = await send_webhook( + webhook=self.webhook, + username=self.webhook_names[maillist], + embed=embed, + wait=True, ) payload["data"][maillist].append(thread_information["thread_id"]) @@ -182,38 +202,6 @@ class PythonNews(Cog): await self.bot.api_client.put("bot/bot-settings/news", json=payload) - async def send_webhook(self, - title: str, - description: str, - timestamp: datetime, - url: str, - webhook_profile_name: str, - footer: str, - author: t.Optional[str] = None, - author_url: t.Optional[str] = None, - ) -> discord.Message: - """Send webhook entry and return sent message.""" - embed = discord.Embed( - title=title, - description=description, - timestamp=timestamp, - url=url, - colour=constants.Colours.soft_green - ) - if author and author_url: - embed.set_author( - name=author, - url=author_url - ) - embed.set_footer(text=footer, icon_url=AVATAR_URL) - - return await self.webhook.send( - embed=embed, - username=sub_clyde(webhook_profile_name), - avatar_url=AVATAR_URL, - wait=True - ) - async def get_thread_and_first_mail(self, maillist: str, thread_identifier: str) -> t.Tuple[t.Any, t.Any]: """Get mail thread and first mail from mail.python.org based on `maillist` and `thread_identifier`.""" async with self.bot.http_session.get( diff --git a/bot/utils/webhooks.py b/bot/utils/webhooks.py index 37fdfe907..66f82ec66 100644 --- a/bot/utils/webhooks.py +++ b/bot/utils/webhooks.py @@ -16,14 +16,14 @@ async def send_webhook( avatar_url: Optional[str] = None, embed: Optional[Embed] = None, wait: Optional[bool] = False -) -> None: +) -> discord.Message: """ Send a message using the provided webhook. This uses sub_clyde() and tries for an HTTPException to ensure it doesn't crash. """ try: - await webhook.send( + return await webhook.send( content=content, username=sub_clyde(username), avatar_url=avatar_url, -- cgit v1.2.3 From 3fce243e15996eb81157c198544fcc705e46e1e6 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 12 Jul 2020 15:27:23 +0200 Subject: Relay all DMs and embeds to #dm-log. https://github.com/python-discord/bot/issues/667 --- bot/__main__.py | 1 + bot/cogs/dm_relay.py | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 bot/cogs/dm_relay.py diff --git a/bot/__main__.py b/bot/__main__.py index 37e62c2f1..49388455a 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -54,6 +54,7 @@ bot.load_extension("bot.cogs.verification") # Feature cogs bot.load_extension("bot.cogs.alias") bot.load_extension("bot.cogs.defcon") +bot.load_extension("bot.cogs.dm_relay") bot.load_extension("bot.cogs.duck_pond") bot.load_extension("bot.cogs.eval") bot.load_extension("bot.cogs.information") diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py new file mode 100644 index 000000000..32ac0e4ee --- /dev/null +++ b/bot/cogs/dm_relay.py @@ -0,0 +1,66 @@ +import logging + +import discord +from discord import Color +from discord.ext.commands import Cog + +from bot import constants +from bot.bot import Bot +from bot.utils.messages import send_attachments +from bot.utils.webhooks import send_webhook + +log = logging.getLogger(__name__) + + +class DMRelay(Cog): + """Debug logging module.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.webhook_id = constants.Webhooks.dm_log + self.webhook = None + self.bot.loop.create_task(self.fetch_webhook()) + + async def fetch_webhook(self) -> None: + """Fetches the webhook object, so we can post to it.""" + await self.bot.wait_until_guild_available() + + try: + self.webhook = await self.bot.fetch_webhook(self.webhook_id) + except discord.HTTPException: + log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") + + @Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """Relays the message's content and attachments to the dm_log channel.""" + clean_content = message.clean_content + if clean_content: + await send_webhook( + webhook=self.webhook, + content=message.clean_content, + username=message.author.display_name, + avatar_url=message.author.avatar_url + ) + + # Handle any attachments + if message.attachments: + try: + await send_attachments(message, self.webhook) + except (discord.errors.Forbidden, discord.errors.NotFound): + e = discord.Embed( + description=":x: **This message contained an attachment, but it could not be retrieved**", + color=Color.red() + ) + await send_webhook( + webhook=self.webhook, + embed=e, + username=message.author.display_name, + avatar_url=message.author.avatar_url + ) + except discord.HTTPException: + log.exception("Failed to send an attachment to the webhook") + + +def setup(bot: Bot) -> None: + """Load the DMRelay cog.""" + bot.add_cog(DMRelay(bot)) -- cgit v1.2.3 From 5007e736b93017003f02a75d12ce1ef8bae9fd69 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 12 Jul 2020 15:34:39 +0200 Subject: Replace channel ID with webhook ID for dm_log. https://github.com/python-discord/bot/issues/667 --- bot/constants.py | 2 +- config-default.yml | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 074699025..3f44003a8 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -416,7 +416,6 @@ class Channels(metaclass=YAMLGetter): user_log: int verification: int voice_log: int - dm_log: int class Webhooks(metaclass=YAMLGetter): @@ -428,6 +427,7 @@ class Webhooks(metaclass=YAMLGetter): reddit: int duck_pond: int dev_log: int + dm_log: int class Roles(metaclass=YAMLGetter): diff --git a/config-default.yml b/config-default.yml index d3ba45f88..c09902a5d 100644 --- a/config-default.yml +++ b/config-default.yml @@ -150,7 +150,6 @@ guild: mod_log: &MOD_LOG 282638479504965634 user_log: 528976905546760203 voice_log: 640292421988646961 - dm_log: 653713721625018428 # Off-topic off_topic_0: 291284109232308226 @@ -252,10 +251,9 @@ guild: duck_pond: 637821475327311927 dev_log: 680501655111729222 python_news: &PYNEWS_WEBHOOK 704381182279942324 - + dm_log: 654567640664244225 filter: - # What do we filter? filter_zalgo: false filter_invites: true -- cgit v1.2.3 From 4349fdedaae43f35f9821aa61c91a1e76908b0b5 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 12 Jul 2020 15:39:40 +0200 Subject: Only relay DMs, and only from humans. https://github.com/python-discord/bot/issues/667 --- bot/cogs/dm_relay.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index 32ac0e4ee..bb060fe90 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -33,6 +33,10 @@ class DMRelay(Cog): @Cog.listener() async def on_message(self, message: discord.Message) -> None: """Relays the message's content and attachments to the dm_log channel.""" + # Only relay DMs from humans + if message.author.bot or message.guild: + return + clean_content = message.clean_content if clean_content: await send_webhook( -- cgit v1.2.3 From df1730ef5d51223fe1d5a2cfe8c027e5177ae9c7 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 12 Jul 2020 16:30:03 +0200 Subject: Fix DuckPond tests now that send_webhook is gone. Some of the tests were failing because they were expecting send_webhook to be a method of the DuckPond cog, other tests simply were no longer applicable, and have been removed. https://github.com/python-discord/bot/issues/667 --- tests/bot/cogs/test_duck_pond.py | 51 ++++++++++------------------------------ 1 file changed, 12 insertions(+), 39 deletions(-) diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index a8c0107c6..cfe10aebf 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -129,38 +129,6 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): ): self.assertEqual(expected_return, actual_return) - def test_send_webhook_correctly_passes_on_arguments(self): - """The `send_webhook` method should pass the arguments to the webhook correctly.""" - self.cog.webhook = helpers.MockAsyncWebhook() - - content = "fake content" - username = "fake username" - avatar_url = "fake avatar_url" - embed = "fake embed" - - asyncio.run(self.cog.send_webhook(content, username, avatar_url, embed)) - - self.cog.webhook.send.assert_called_once_with( - content=content, - username=username, - avatar_url=avatar_url, - embed=embed - ) - - def test_send_webhook_logs_when_sending_message_fails(self): - """The `send_webhook` method should catch a `discord.HTTPException` and log accordingly.""" - self.cog.webhook = helpers.MockAsyncWebhook() - self.cog.webhook.send.side_effect = discord.HTTPException(response=MagicMock(), message="Something failed.") - - log = logging.getLogger('bot.cogs.duck_pond') - with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: - asyncio.run(self.cog.send_webhook()) - - self.assertEqual(len(log_watcher.records), 1) - - record = log_watcher.records[0] - self.assertEqual(record.levelno, logging.ERROR) - def _get_reaction( self, emoji: typing.Union[str, helpers.MockEmoji], @@ -280,16 +248,20 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): async def test_relay_message_correctly_relays_content_and_attachments(self): """The `relay_message` method should correctly relay message content and attachments.""" - send_webhook_path = f"{MODULE_PATH}.DuckPond.send_webhook" + send_webhook_path = f"{MODULE_PATH}.send_webhook" send_attachments_path = f"{MODULE_PATH}.send_attachments" + author = MagicMock( + display_name="x", + avatar_url="https://" + ) self.cog.webhook = helpers.MockAsyncWebhook() test_values = ( - (helpers.MockMessage(clean_content="", attachments=[]), False, False), - (helpers.MockMessage(clean_content="message", attachments=[]), True, False), - (helpers.MockMessage(clean_content="", attachments=["attachment"]), False, True), - (helpers.MockMessage(clean_content="message", attachments=["attachment"]), True, True), + (helpers.MockMessage(author=author, clean_content="", attachments=[]), False, False), + (helpers.MockMessage(author=author, clean_content="message", attachments=[]), True, False), + (helpers.MockMessage(author=author, clean_content="", attachments=["attachment"]), False, True), + (helpers.MockMessage(author=author, clean_content="message", attachments=["attachment"]), True, True), ) for message, expect_webhook_call, expect_attachment_call in test_values: @@ -314,14 +286,14 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): for side_effect in side_effects: # pragma: no cover send_attachments.side_effect = side_effect - with patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=AsyncMock) as send_webhook: + with patch(f"{MODULE_PATH}.send_webhook", new_callable=AsyncMock) as send_webhook: with self.subTest(side_effect=type(side_effect).__name__): with self.assertNotLogs(logger=log, level=logging.ERROR): await self.cog.relay_message(message) self.assertEqual(send_webhook.call_count, 2) - @patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=AsyncMock) + @patch(f"{MODULE_PATH}.send_webhook", new_callable=AsyncMock) @patch(f"{MODULE_PATH}.send_attachments", new_callable=AsyncMock) async def test_relay_message_handles_attachment_http_error(self, send_attachments, send_webhook): """The `relay_message` method should handle irretrievable attachments.""" @@ -337,6 +309,7 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): await self.cog.relay_message(message) send_webhook.assert_called_once_with( + webhook=self.cog.webhook, content=message.clean_content, username=message.author.display_name, avatar_url=message.author.avatar_url -- cgit v1.2.3 From aaf8db7550e8b95354d6f079c99ef2beb400cac8 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 12 Jul 2020 20:19:54 +0200 Subject: Add a way to respond to DMs. This shouldn't be used as a replacement for ModMail, but I think it makes sense to have the feature just in case #dm-log provides an interesting use-case where responding as the bot makes sense. It's a bit of a curiosity, and Ves hates it, but I included it anyway. https://github.com/python-discord/bot/issues/667 --- bot/cogs/dm_relay.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index bb060fe90..df19000fe 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -1,7 +1,9 @@ import logging +from typing import Optional import discord from discord import Color +from discord.ext import commands from discord.ext.commands import Cog from bot import constants @@ -20,6 +22,32 @@ class DMRelay(Cog): self.webhook_id = constants.Webhooks.dm_log self.webhook = None self.bot.loop.create_task(self.fetch_webhook()) + self.last_dm_user = None + + @commands.command(aliases=("reply",)) + async def send_dm(self, ctx: commands.Context, member: Optional[discord.Member], *, message: str) -> None: + """ + Allows you to send a DM to a user from the bot. + + If `member` is not provided, it will send to the last user who DM'd the bot. + + This feature should be used extremely sparingly. Use ModMail if you need to have a serious + conversation with a user. This is just for responding to extraordinary DMs, having a little + fun with users, and telling people they are DMing the wrong bot. + + NOTE: This feature will be removed if it is overused. + """ + if member: + await member.send(message) + await ctx.message.add_reaction("✅") + return + elif self.last_dm_user: + await self.last_dm_user.send(message) + await ctx.message.add_reaction("✅") + return + else: + log.debug("Unable to send a DM to the user.") + await ctx.message.add_reaction("❌") async def fetch_webhook(self) -> None: """Fetches the webhook object, so we can post to it.""" @@ -45,6 +73,7 @@ class DMRelay(Cog): username=message.author.display_name, avatar_url=message.author.avatar_url ) + self.last_dm_user = message.author # Handle any attachments if message.attachments: -- cgit v1.2.3 From 4527c038d21149d4d3fab73c54b9a1ad31e671c0 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 13 Jul 2020 00:17:37 +0200 Subject: Revert "Ping @Moderators in ModLog" Let's continue to use "@everyone" for now, and add an explicit allow for it so that it successfully pings people. There's a full justification for this in the pull request. https://github.com/python-discord/bot/issues/1038 --- bot/cogs/antispam.py | 4 ++-- bot/cogs/filtering.py | 2 +- bot/cogs/moderation/modlog.py | 17 +++++++++++------ bot/cogs/watchchannels/watchchannel.py | 4 ++-- bot/constants.py | 4 ++-- config-default.yml | 4 ++-- 6 files changed, 20 insertions(+), 15 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 71382bba9..0bcca578d 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -98,7 +98,7 @@ class DeletionContext: text=mod_alert_message, thumbnail=last_message.author.avatar_url_as(static_format="png"), channel_id=Channels.mod_alerts, - ping_moderators=AntiSpamConfig.ping_moderators + ping_everyone=AntiSpamConfig.ping_everyone ) @@ -132,7 +132,7 @@ class AntiSpam(Cog): await self.mod_log.send_log_message( title="Error: AntiSpam configuration validation failed!", text=body, - ping_moderators=True, + ping_everyone=True, icon_url=Icons.token_removed, colour=Colour.red() ) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index a5d59085f..76ea68660 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -329,7 +329,7 @@ class Filtering(Cog, Scheduler): text=message, thumbnail=msg.author.avatar_url_as(static_format="png"), channel_id=Channels.mod_alerts, - ping_moderators=Filter.ping_moderators, + ping_everyone=Filter.ping_everyone, additional_embeds=additional_embeds, additional_embeds_msg=additional_embeds_msg ) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index a37a9faf5..0a63f57b8 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -15,7 +15,7 @@ from discord.ext.commands import Cog, Context from discord.utils import escape_markdown from bot.bot import Bot -from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, Roles, URLs +from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs from bot.utils.time import humanize_delta log = logging.getLogger(__name__) @@ -88,7 +88,7 @@ class ModLog(Cog, name="ModLog"): text: str, thumbnail: t.Optional[t.Union[str, discord.Asset]] = None, channel_id: int = Channels.mod_log, - ping_moderators: bool = False, + ping_everyone: bool = False, files: t.Optional[t.List[discord.File]] = None, content: t.Optional[str] = None, additional_embeds: t.Optional[t.List[discord.Embed]] = None, @@ -114,14 +114,19 @@ class ModLog(Cog, name="ModLog"): if thumbnail: embed.set_thumbnail(url=thumbnail) - if ping_moderators: + if ping_everyone: if content: - content = f"<@&{Roles.moderators}>\n{content}" + content = f"@everyone\n{content}" else: - content = f"<@&{Roles.moderators}>" + content = "@everyone" channel = self.bot.get_channel(channel_id) - log_message = await channel.send(content=content, embed=embed, files=files) + log_message = await channel.send( + content=content, + embed=embed, + files=files, + allowed_mentions=discord.AllowedMentions(everyone=True) + ) if additional_embeds: if additional_embeds_msg: diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 8c4af4581..7c58a0fb5 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -120,7 +120,7 @@ class WatchChannel(metaclass=CogABCMeta): await self.modlog.send_log_message( title=f"Error: Failed to initialize the {self.__class__.__name__} watch channel", text=message, - ping_moderators=True, + ping_everyone=True, icon_url=Icons.token_removed, colour=Color.red() ) @@ -132,7 +132,7 @@ class WatchChannel(metaclass=CogABCMeta): await self.modlog.send_log_message( title=f"Warning: Failed to retrieve user cache for the {self.__class__.__name__} watch channel", text="Could not retrieve the list of watched users from the API and messages will not be relayed.", - ping_moderators=True, + ping_everyone=True, icon_url=Icons.token_removed, colour=Color.red() ) diff --git a/bot/constants.py b/bot/constants.py index 34b312d2d..a1b392c82 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -225,7 +225,7 @@ class Filter(metaclass=YAMLGetter): notify_user_invites: bool notify_user_domains: bool - ping_moderators: bool + ping_everyone: bool offensive_msg_delete_days: int guild_invite_whitelist: List[int] domain_blacklist: List[str] @@ -522,7 +522,7 @@ class AntiSpam(metaclass=YAMLGetter): section = 'anti_spam' clean_offending: bool - ping_moderators: bool + ping_everyone: bool punishment: Dict[str, Dict[str, int]] rules: Dict[str, Dict[str, int]] diff --git a/config-default.yml b/config-default.yml index 0f6a25ef2..636b9db37 100644 --- a/config-default.yml +++ b/config-default.yml @@ -269,7 +269,7 @@ filter: notify_user_domains: false # Filter configuration - ping_moderators: true + ping_everyone: true offensive_msg_delete_days: 7 # How many days before deleting an offensive message? guild_invite_whitelist: @@ -428,7 +428,7 @@ urls: anti_spam: # Clean messages that violate a rule. clean_offending: true - ping_moderators: true + ping_everyone: true punishment: role_id: *MUTED_ROLE -- cgit v1.2.3 From e1c3b66f5f4d1f421d6469bd4f0964166262832c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 12 Jul 2020 23:49:46 -0700 Subject: Fix rescheduling of edited infractions It was attempting to schedule a dictionary instead of a coroutine. Fixes #1043 Fixes BOT-6Y --- bot/cogs/moderation/management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 4ef9d4209..672bb0e9c 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -139,7 +139,7 @@ class ModManagement(commands.Cog): # If the infraction was not marked as permanent, schedule a new expiration task if request_data['expires_at']: - self.infractions_cog.scheduler.schedule(new_infraction['id'], new_infraction) + self.infractions_cog.schedule_expiration(new_infraction) log_text += f""" Previous expiry: {old_infraction['expires_at'] or "Permanent"} -- cgit v1.2.3 From c4e9060a76a901c7d2e6035e6ca19d51770a4ab3 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Mon, 13 Jul 2020 15:04:40 +0200 Subject: Incidents: add `download_file` helper & tests Co-authored-by: MarkKoz --- bot/cogs/moderation/incidents.py | 13 +++++++++++++ tests/bot/cogs/moderation/test_incidents.py | 20 ++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index be46c8202..65b0e458e 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -42,6 +42,19 @@ ALLOWED_ROLES: t.Set[int] = set(Guild.moderation_roles) ALL_SIGNALS: t.Set[str] = {signal.value for signal in Signal} +async def download_file(attachment: discord.Attachment) -> t.Optional[discord.File]: + """ + Download & return `attachment` file. + + If the download fails, the reason is logged and None will be returned. + """ + log.debug(f"Attempting to download attachment: {attachment.filename}") + try: + return await attachment.to_file() + except Exception: + log.exception("Failed to download attachment") + + def make_embed(incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> discord.Embed: """ Create an embed representation of `incident` for the #incidents-archive channel. diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 789a37cd4..273916199 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -12,6 +12,7 @@ from bot.cogs.moderation import Incidents, incidents from bot.constants import Colours from tests.helpers import ( MockAsyncWebhook, + MockAttachment, MockBot, MockMember, MockMessage, @@ -69,6 +70,25 @@ mock_404 = discord.NotFound( ) +class TestDownloadFile(unittest.IsolatedAsyncioTestCase): + """Collection of tests for the `download_file` helper function.""" + + async def test_download_file_success(self): + """If `to_file` succeeds, function returns the acquired `discord.File`.""" + file = MagicMock(discord.File, filename="bigbadlemon.jpg") + attachment = MockAttachment(to_file=AsyncMock(return_value=file)) + + acquired_file = await incidents.download_file(attachment) + self.assertIs(file, acquired_file) + + async def test_download_file_fail(self): + """If `to_file` fails, function handles the exception & returns None.""" + attachment = MockAttachment(to_file=AsyncMock(side_effect=mock_404)) + + acquired_file = await incidents.download_file(attachment) + self.assertIsNone(acquired_file) + + class TestMakeEmbed(unittest.TestCase): """Collection of tests for the `make_embed` helper function.""" -- cgit v1.2.3 From ed2368791870bd0b464391d9da7b13de15b322a3 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 13 Jul 2020 15:13:39 +0200 Subject: Better docstring for DMRelay cog. https://github.com/python-discord/bot/issues/667 --- bot/cogs/dm_relay.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index df19000fe..c6206629e 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -15,7 +15,7 @@ log = logging.getLogger(__name__) class DMRelay(Cog): - """Debug logging module.""" + """Relay direct messages to and from the bot.""" def __init__(self, bot: Bot): self.bot = bot -- cgit v1.2.3 From ab1546611a9952ddb45f211901ad129c2e8c5007 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 13 Jul 2020 15:14:31 +0200 Subject: Add avatar_url in python_news.py https://github.com/python-discord/bot/issues/667 --- bot/cogs/python_news.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/python_news.py b/bot/cogs/python_news.py index 1d8f2aeb0..0ab5738a4 100644 --- a/bot/cogs/python_news.py +++ b/bot/cogs/python_news.py @@ -113,6 +113,7 @@ class PythonNews(Cog): webhook=self.webhook, username=data["feed"]["title"], embed=embed, + avatar_url=AVATAR_URL, wait=True, ) payload["data"]["pep"].append(pep_nr) @@ -189,6 +190,7 @@ class PythonNews(Cog): webhook=self.webhook, username=self.webhook_names[maillist], embed=embed, + avatar_url=AVATAR_URL, wait=True, ) payload["data"][maillist].append(thread_information["thread_id"]) -- cgit v1.2.3 From 87c2ef7610a42207b0289820458285648f5dd41e Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 13 Jul 2020 15:20:59 +0200 Subject: Only mods+ may use the commands in this cog. https://github.com/python-discord/bot/issues/667 --- bot/cogs/dm_relay.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index c6206629e..67411f57b 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -8,6 +8,8 @@ from discord.ext.commands import Cog from bot import constants from bot.bot import Bot +from bot.constants import MODERATION_ROLES +from bot.utils.checks import with_role_check from bot.utils.messages import send_attachments from bot.utils.webhooks import send_webhook @@ -93,6 +95,10 @@ class DMRelay(Cog): except discord.HTTPException: log.exception("Failed to send an attachment to the webhook") + def cog_check(self, ctx: commands.Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + return with_role_check(ctx, *MODERATION_ROLES) + def setup(bot: Bot) -> None: """Load the DMRelay cog.""" -- cgit v1.2.3 From 311936991d5543e35dbe4a5a5a13261fb44c27f4 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 13 Jul 2020 15:24:58 +0200 Subject: Don't run on_message if self.webhook is None. https://github.com/python-discord/bot/issues/667 --- bot/cogs/dm_relay.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index 67411f57b..3d16db8a0 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -64,7 +64,7 @@ class DMRelay(Cog): async def on_message(self, message: discord.Message) -> None: """Relays the message's content and attachments to the dm_log channel.""" # Only relay DMs from humans - if message.author.bot or message.guild: + if message.author.bot or message.guild or self.webhook is None: return clean_content = message.clean_content -- cgit v1.2.3 From d98a67f36444a7732f4527d8c343e2fb8fad6f93 Mon Sep 17 00:00:00 2001 From: Slushie Date: Mon, 13 Jul 2020 14:25:07 +0100 Subject: rename the `_filter_eval` function to be a public function --- bot/cogs/filtering.py | 2 +- bot/cogs/snekbox.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 4c97073c3..ec6769f68 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -200,7 +200,7 @@ class Filtering(Cog, Scheduler): # Update time when alert sent await self.name_alerts.set(member.id, datetime.utcnow().timestamp()) - async def _filter_eval(self, result: str, msg: Message) -> bool: + async def filter_eval(self, result: str, msg: Message) -> bool: """ Filter the result of an !eval to see if it violates any of our rules, and then respond accordingly. diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 649bab492..4f73690da 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -213,7 +213,7 @@ class Snekbox(Cog): self.bot.stats.incr("snekbox.python.success") filter_cog = self.bot.get_cog("Filtering") - filter_triggered = await filter_cog._filter_eval(msg, ctx.message) + filter_triggered = await filter_cog.filter_eval(msg, ctx.message) if filter_triggered: response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.") else: -- cgit v1.2.3 From aa0b20bdd780bc75cadde781981d063287bfe5ce Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 13 Jul 2020 15:27:41 +0200 Subject: Remove redundant clean_content variable. https://github.com/python-discord/bot/issues/667 --- bot/cogs/dm_relay.py | 3 +-- bot/cogs/duck_pond.py | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index 3d16db8a0..494c71066 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -67,8 +67,7 @@ class DMRelay(Cog): if message.author.bot or message.guild or self.webhook is None: return - clean_content = message.clean_content - if clean_content: + if message.clean_content: await send_webhook( webhook=self.webhook, content=message.clean_content, diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index 89b4ad0e4..7021069fa 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -78,9 +78,7 @@ class DuckPond(Cog): async def relay_message(self, message: Message) -> None: """Relays the message's content and attachments to the duck pond channel.""" - clean_content = message.clean_content - - if clean_content: + if message.clean_content: await send_webhook( webhook=self.webhook, content=message.clean_content, -- cgit v1.2.3 From b40a5f0de6758eb9dfb79ac3f34fbc0bf90d8a1e Mon Sep 17 00:00:00 2001 From: Slushie Date: Mon, 13 Jul 2020 16:08:40 +0100 Subject: check for the filter_cog in case it is unloaded --- bot/cogs/snekbox.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 4f73690da..662f90869 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -213,7 +213,9 @@ class Snekbox(Cog): self.bot.stats.incr("snekbox.python.success") filter_cog = self.bot.get_cog("Filtering") - filter_triggered = await filter_cog.filter_eval(msg, ctx.message) + filter_triggered = False + if filter_cog: + filter_triggered = await filter_cog.filter_eval(msg, ctx.message) if filter_triggered: response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.") else: -- cgit v1.2.3 From f1b1d0cb723abbbf7d4b49ac4b42fe0b7f266692 Mon Sep 17 00:00:00 2001 From: Slushie Date: Mon, 13 Jul 2020 16:09:08 +0100 Subject: edit snekbox tests to work with filtering --- tests/bot/cogs/test_snekbox.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index cf9adbee0..98dee7a1b 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -233,6 +233,10 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): self.cog.get_status_emoji = MagicMock(return_value=':yay!:') self.cog.format_output = AsyncMock(return_value=('[No output]', None)) + mocked_filter_cog = MagicMock() + mocked_filter_cog.filter_eval = AsyncMock(return_value=False) + self.bot.get_cog.return_value = mocked_filter_cog + await self.cog.send_eval(ctx, 'MyAwesomeCode') ctx.send.assert_called_once_with( '@LemonLemonishBeard#0042 :yay!: Return code 0.\n\n```py\n[No output]\n```' @@ -254,6 +258,10 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): self.cog.get_status_emoji = MagicMock(return_value=':yay!:') self.cog.format_output = AsyncMock(return_value=('Way too long beard', 'lookatmybeard.com')) + mocked_filter_cog = MagicMock() + mocked_filter_cog.filter_eval = AsyncMock(return_value=False) + self.bot.get_cog.return_value = mocked_filter_cog + await self.cog.send_eval(ctx, 'MyAwesomeCode') ctx.send.assert_called_once_with( '@LemonLemonishBeard#0042 :yay!: Return code 0.' @@ -275,6 +283,10 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): self.cog.get_status_emoji = MagicMock(return_value=':nope!:') self.cog.format_output = AsyncMock() # This function isn't called + mocked_filter_cog = MagicMock() + mocked_filter_cog.filter_eval = AsyncMock(return_value=False) + self.bot.get_cog.return_value = mocked_filter_cog + await self.cog.send_eval(ctx, 'MyAwesomeCode') ctx.send.assert_called_once_with( '@LemonLemonishBeard#0042 :nope!: Return code 127.\n\n```py\nBeard got stuck in the eval\n```' -- cgit v1.2.3 From ea62b6bae85113be913101a41053c91497b23c9a Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 13 Jul 2020 21:09:41 +0200 Subject: Store last DM user in RedisCache. Also now catches the exception if a user has disabled DMs, and adds a red cross reaction. https://github.com/python-discord/bot/issues/667 --- bot/cogs/dm_relay.py | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index 494c71066..3fce52b93 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -9,6 +9,7 @@ from discord.ext.commands import Cog from bot import constants from bot.bot import Bot from bot.constants import MODERATION_ROLES +from bot.utils import RedisCache from bot.utils.checks import with_role_check from bot.utils.messages import send_attachments from bot.utils.webhooks import send_webhook @@ -19,12 +20,14 @@ log = logging.getLogger(__name__) class DMRelay(Cog): """Relay direct messages to and from the bot.""" + # RedisCache[str, t.Union[discord.User.id, discord.Member.id]] + dm_cache = RedisCache() + def __init__(self, bot: Bot): self.bot = bot self.webhook_id = constants.Webhooks.dm_log self.webhook = None self.bot.loop.create_task(self.fetch_webhook()) - self.last_dm_user = None @commands.command(aliases=("reply",)) async def send_dm(self, ctx: commands.Context, member: Optional[discord.Member], *, message: str) -> None: @@ -39,16 +42,23 @@ class DMRelay(Cog): NOTE: This feature will be removed if it is overused. """ - if member: - await member.send(message) - await ctx.message.add_reaction("✅") - return - elif self.last_dm_user: - await self.last_dm_user.send(message) - await ctx.message.add_reaction("✅") - return - else: - log.debug("Unable to send a DM to the user.") + user_id = await self.dm_cache.get("last_user") + last_dm_user = ctx.guild.get_member(user_id) if user_id else None + + try: + if member: + await member.send(message) + await ctx.message.add_reaction("✅") + return + elif last_dm_user: + await last_dm_user.send(message) + await ctx.message.add_reaction("✅") + return + else: + log.debug("This bot has never gotten a DM, or the RedisCache has been cleared.") + await ctx.message.add_reaction("❌") + except discord.errors.Forbidden: + log.debug("User has disabled DMs.") await ctx.message.add_reaction("❌") async def fetch_webhook(self) -> None: @@ -74,7 +84,7 @@ class DMRelay(Cog): username=message.author.display_name, avatar_url=message.author.avatar_url ) - self.last_dm_user = message.author + await self.dm_cache.set("last_user", message.author.id) # Handle any attachments if message.attachments: -- cgit v1.2.3 From c91ad4b74d4aea220ef564af3b1c044ab81a01d8 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 13 Jul 2020 21:19:30 +0200 Subject: Whitelisting some popular communities The following communities are whitelisted by this commit: - Django - Programming Discussions - JetBrains Community - Raspberry Pi - Programmers Hangout - SpeakJS - DevCord - Unity - Programmer Humor - Microsoft Community Most of these are partners, or otherwise friendly communities that aren't worth pinging mods over. --- config-default.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/config-default.yml b/config-default.yml index 636b9db37..19d79fa76 100644 --- a/config-default.yml +++ b/config-default.yml @@ -295,12 +295,22 @@ filter: - 172018499005317120 # The Coding Den - 666560367173828639 # PyWeek - 702724176489873509 # Microsoft Python + - 150662382874525696 # Microsoft Community - 81384788765712384 # Discord API - 613425648685547541 # Discord Developers - 185590609631903755 # Blender Hub - 420324994703163402 # /r/FlutterDev - 488751051629920277 # Python Atlanta - 143867839282020352 # C# + - 159039020565790721 # Django + - 238666723824238602 # Programming Discussions + - 433980600391696384 # JetBrains Community + - 204621105720328193 # Raspberry Pi + - 244230771232079873 # Programmers Hangout + - 239433591950540801 # SpeakJS + - 174075418410876928 # DevCord + - 489222168727519232 # Unity + - 494558898880118785 # Programmer Humor domain_blacklist: - pornhub.com -- cgit v1.2.3 From 1fb9bdb0deb3609f426a3bca555c67d0a7dc52a7 Mon Sep 17 00:00:00 2001 From: Slushie Date: Tue, 14 Jul 2020 00:39:31 +0100 Subject: fix misaligned indentation --- bot/cogs/filtering.py | 74 +++++++++++++++++++++++++-------------------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index ec6769f68..2de00f3a1 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -217,43 +217,43 @@ class Filtering(Cog, Scheduler): if _filter["enabled"] and _filter["content_only"]: match = await _filter["function"](result) - if match: - # If this is a filter (not a watchlist), we set the variable so we know - # that it has been triggered - if _filter["type"] == "filter": - filter_triggered = True - - # We do not have to check against DM channels since !eval cannot be used there. - channel_str = f"in {msg.channel.mention}" - - message_content, additional_embeds, additional_embeds_msg = self._add_stats( - filter_name, match, result - ) - - message = ( - f"The {filter_name} {_filter['type']} was triggered " - f"by **{msg.author}** " - f"(`{msg.author.id}`) {channel_str} using !eval with " - f"[the following message]({msg.jump_url}):\n\n" - f"{message_content}" - ) - - log.debug(message) - - # Send pretty mod log embed to mod-alerts - await self.mod_log.send_log_message( - icon_url=Icons.filtering, - colour=Colour(Colours.soft_red), - title=f"{_filter['type'].title()} triggered!", - text=message, - thumbnail=msg.author.avatar_url_as(static_format="png"), - channel_id=Channels.mod_alerts, - ping_everyone=Filter.ping_everyone, - additional_embeds=additional_embeds, - additional_embeds_msg=additional_embeds_msg - ) - - break # We don't want multiple filters to trigger + if match: + # If this is a filter (not a watchlist), we set the variable so we know + # that it has been triggered + if _filter["type"] == "filter": + filter_triggered = True + + # We do not have to check against DM channels since !eval cannot be used there. + channel_str = f"in {msg.channel.mention}" + + message_content, additional_embeds, additional_embeds_msg = self._add_stats( + filter_name, match, result + ) + + message = ( + f"The {filter_name} {_filter['type']} was triggered " + f"by **{msg.author}** " + f"(`{msg.author.id}`) {channel_str} using !eval with " + f"[the following message]({msg.jump_url}):\n\n" + f"{message_content}" + ) + + log.debug(message) + + # Send pretty mod log embed to mod-alerts + await self.mod_log.send_log_message( + icon_url=Icons.filtering, + colour=Colour(Colours.soft_red), + title=f"{_filter['type'].title()} triggered!", + text=message, + thumbnail=msg.author.avatar_url_as(static_format="png"), + channel_id=Channels.mod_alerts, + ping_everyone=Filter.ping_everyone, + additional_embeds=additional_embeds, + additional_embeds_msg=additional_embeds_msg + ) + + break # We don't want multiple filters to trigger return filter_triggered -- cgit v1.2.3 From 7ff8b2c9ebc27194835c25258bc90c623bdbec6b Mon Sep 17 00:00:00 2001 From: kosayoda Date: Tue, 14 Jul 2020 10:51:56 +0800 Subject: Allow ordering watched users by oldest first --- bot/cogs/watchchannels/watchchannel.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 7c58a0fb5..2992a3085 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -287,7 +287,9 @@ class WatchChannel(metaclass=CogABCMeta): await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.avatar_url) - async def list_watched_users(self, ctx: Context, update_cache: bool = True) -> None: + async def list_watched_users( + self, ctx: Context, oldest_first: bool = False, update_cache: bool = True + ) -> None: """ Gives an overview of the watched user list for this channel. @@ -305,7 +307,11 @@ class WatchChannel(metaclass=CogABCMeta): time_delta = self._get_time_delta(inserted_at) lines.append(f"• <@{user_id}> (added {time_delta})") + if oldest_first: + lines.reverse() + lines = lines or ("There's nothing here yet.",) + embed = Embed( title=f"{self.__class__.__name__} watched users ({'updated' if update_cache else 'cached'})", color=Color.blue() -- cgit v1.2.3 From a6f66611b17aab8fbc3c54377c3971faeac5073b Mon Sep 17 00:00:00 2001 From: kosayoda Date: Tue, 14 Jul 2020 10:52:33 +0800 Subject: Pass argument as kwarg to preserve functionality --- bot/cogs/watchchannels/bigbrother.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 702d371f4..fc899281b 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -42,7 +42,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): The optional kwarg `update_cache` can be used to update the user cache using the API before listing the users. """ - await self.list_watched_users(ctx, update_cache) + await self.list_watched_users(ctx, update_cache=update_cache) @bigbrother_group.command(name='watch', aliases=('w',)) @with_role(*MODERATION_ROLES) -- cgit v1.2.3 From ac7ed93eb64884a622c8718c8594aa74a3b2b201 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Tue, 14 Jul 2020 10:58:02 +0800 Subject: Accept argument to order nominees by oldest first --- bot/cogs/watchchannels/talentpool.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 33550f68e..1f5989f23 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -38,14 +38,18 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @nomination_group.command(name='watched', aliases=('all', 'list')) @with_role(*MODERATION_ROLES) - async def watched_command(self, ctx: Context, update_cache: bool = True) -> None: + async def watched_command( + self, ctx: Context, oldest_first: bool = False, update_cache: bool = True + ) -> None: """ Shows the users that are currently being monitored in the talent pool. + The optional kwarg `oldest_first` can be used to order the list by oldest nomination. + The optional kwarg `update_cache` can be used to update the user cache using the API before listing the users. """ - await self.list_watched_users(ctx, update_cache) + await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) @nomination_group.command(name='watch', aliases=('w', 'add', 'a')) @with_role(*STAFF_ROLES) -- cgit v1.2.3 From 01d2803b608407330959ef880bd562456921d0fd Mon Sep 17 00:00:00 2001 From: kosayoda Date: Tue, 14 Jul 2020 10:58:56 +0800 Subject: Add command to list nominees by oldest first --- bot/cogs/watchchannels/talentpool.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 1f5989f23..89256e92e 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -51,6 +51,17 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): """ await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) + @nomination_group.command(name='oldest') + @with_role(*MODERATION_ROLES) + async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: + """ + Shows talent pool monitored users ordered by oldest nomination. + + The optional kwarg `update_cache` can be used to update the user + cache using the API before listing the users. + """ + await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) + @nomination_group.command(name='watch', aliases=('w', 'add', 'a')) @with_role(*STAFF_ROLES) async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: -- cgit v1.2.3 From 28fe47d5d2404cdc70eaabe3e6c41567b9fd7c3d Mon Sep 17 00:00:00 2001 From: kosayoda Date: Tue, 14 Jul 2020 11:55:00 +0800 Subject: Achieve feature parity with talentpool --- bot/cogs/watchchannels/bigbrother.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index fc899281b..4d27a6333 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -35,14 +35,29 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): @bigbrother_group.command(name='watched', aliases=('all', 'list')) @with_role(*MODERATION_ROLES) - async def watched_command(self, ctx: Context, update_cache: bool = True) -> None: + async def watched_command( + self, ctx: Context, oldest_first: bool = False, update_cache: bool = True + ) -> None: """ Shows the users that are currently being monitored by Big Brother. + The optional kwarg `oldest_first` can be used to order the list by oldest watched. + + The optional kwarg `update_cache` can be used to update the user + cache using the API before listing the users. + """ + await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) + + @bigbrother_group.command(name='oldest') + @with_role(*MODERATION_ROLES) + async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: + """ + Shows Big Brother monitored users ordered by oldest watched. + The optional kwarg `update_cache` can be used to update the user cache using the API before listing the users. """ - await self.list_watched_users(ctx, update_cache=update_cache) + await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) @bigbrother_group.command(name='watch', aliases=('w',)) @with_role(*MODERATION_ROLES) -- cgit v1.2.3 From 8d62214b0b009d1cc9b343c9589a5a1fe8f4692b Mon Sep 17 00:00:00 2001 From: kosayoda Date: Tue, 14 Jul 2020 12:49:39 +0800 Subject: Invoke fuzzywuzzy's processor before matching Trying to match a string with only non-alphanumeric characters results in a warning by fuzzywuzzy. Processing the string before matching lets us avoid the warning, which which uses the root logger and thus isn't supressible. --- bot/cogs/help.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 832f6ea6b..198e88b55 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -8,6 +8,7 @@ from typing import List, Union from discord import Colour, Embed, Member, Message, NotFound, Reaction, User from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand from fuzzywuzzy import fuzz, process +from fuzzywuzzy.utils import full_process from bot import constants from bot.constants import Channels, Emojis, STAFF_ROLES @@ -146,7 +147,13 @@ class CustomHelpCommand(HelpCommand): Will return an instance of the `HelpQueryNotFound` exception with the error message and possible matches. """ choices = await self.get_all_help_choices() - result = process.extractBests(string, choices, scorer=fuzz.ratio, score_cutoff=60) + + # Run fuzzywuzzy's processor beforehand, and avoid matching if processed string is empty + # This avoids fuzzywuzzy from raising a warning on inputs with only non-alphanumeric characters + if full_process(string): + result = process.extractBests(string, choices, scorer=fuzz.ratio, score_cutoff=60, processor=None) + else: + result = [] return HelpQueryNotFound(f'Query "{string}" not found.', dict(result)) -- cgit v1.2.3 From 3c1d43dc83b4a3d3a02492a1d045c7b9f1735feb Mon Sep 17 00:00:00 2001 From: kosayoda Date: Tue, 14 Jul 2020 13:22:08 +0800 Subject: Remove redundant kwarg in !kick and !shadow_kick The kwarg `active=False` is already being passed in `apply_kick`, therefore passing it in the parent callers result in a TypeError. Fixes #976 Fixes BOT-5P --- bot/cogs/moderation/infractions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 3b28526b2..8df642428 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -64,7 +64,7 @@ class Infractions(InfractionScheduler, commands.Cog): @command() async def kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: """Kick a user for the given reason.""" - await self.apply_kick(ctx, user, reason, active=False) + await self.apply_kick(ctx, user, reason) @command() async def ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: @@ -134,7 +134,7 @@ class Infractions(InfractionScheduler, commands.Cog): @command(hidden=True, aliases=['shadowkick', 'skick']) async def shadow_kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: """Kick a user for the given reason without notifying the user.""" - await self.apply_kick(ctx, user, reason, hidden=True, active=False) + await self.apply_kick(ctx, user, reason, hidden=True) @command(hidden=True, aliases=['shadowban', 'sban']) async def shadow_ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: -- cgit v1.2.3 From b35fad987b2b6de8da2c36bb02f4e0c6777b9737 Mon Sep 17 00:00:00 2001 From: ItsCinnabar <50111163+ItsCinnabar@users.noreply.github.com> Date: Tue, 14 Jul 2020 09:48:31 -0400 Subject: Update or-gotcha.md Adjust description and include link to docs --- bot/resources/tags/or-gotcha.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/resources/tags/or-gotcha.md b/bot/resources/tags/or-gotcha.md index 00c2db1f8..2dc1410ad 100644 --- a/bot/resources/tags/or-gotcha.md +++ b/bot/resources/tags/or-gotcha.md @@ -3,7 +3,7 @@ When checking if something is equal to one thing or another, you might think tha if favorite_fruit == 'grapefruit' or 'lemon': print("That's a weird favorite fruit to have.") ``` -After all, that's how you would normally phrase it in plain English. In Python, however, you have to have _complete instructions on both sides of the logical operator_. +While this makes sense in English, it may not behave the way you would expect. In Python, you should have _complete instructions on both sides of the logical operator_. So, if you want to check if something is equal to one thing or another, there are two common ways: ```py @@ -15,3 +15,4 @@ if favorite_fruit == 'grapefruit' or favorite_fruit == 'lemon': if favorite_fruit in ('grapefruit', 'lemon'): print("That's a weird favorite fruit to have.") ``` +For more info, see here: [Python Docs - Boolean Operations](https://docs.python.org/3/reference/expressions.html#boolean-operations) -- cgit v1.2.3 From 18e58ad1e040f3997a23308d916eed7d474a5dd6 Mon Sep 17 00:00:00 2001 From: ItsCinnabar <50111163+ItsCinnabar@users.noreply.github.com> Date: Tue, 14 Jul 2020 10:03:07 -0400 Subject: Update or-gotcha.md --- bot/resources/tags/or-gotcha.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/or-gotcha.md b/bot/resources/tags/or-gotcha.md index 2dc1410ad..cbb64c276 100644 --- a/bot/resources/tags/or-gotcha.md +++ b/bot/resources/tags/or-gotcha.md @@ -3,7 +3,7 @@ When checking if something is equal to one thing or another, you might think tha if favorite_fruit == 'grapefruit' or 'lemon': print("That's a weird favorite fruit to have.") ``` -While this makes sense in English, it may not behave the way you would expect. In Python, you should have _complete instructions on both sides of the logical operator_. +While this makes sense in English, it may not behave the way you would expect. [In Python, you should have _complete instructions on both sides of the logical operator_.](https://docs.python.org/3/reference/expressions.html#boolean-operations) So, if you want to check if something is equal to one thing or another, there are two common ways: ```py -- cgit v1.2.3 From 6d064912e5c2caf29de609955f78eb60014a5b63 Mon Sep 17 00:00:00 2001 From: ItsCinnabar <50111163+ItsCinnabar@users.noreply.github.com> Date: Tue, 14 Jul 2020 10:06:00 -0400 Subject: Update or-gotcha.md --- bot/resources/tags/or-gotcha.md | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/resources/tags/or-gotcha.md b/bot/resources/tags/or-gotcha.md index cbb64c276..00c8a5645 100644 --- a/bot/resources/tags/or-gotcha.md +++ b/bot/resources/tags/or-gotcha.md @@ -15,4 +15,3 @@ if favorite_fruit == 'grapefruit' or favorite_fruit == 'lemon': if favorite_fruit in ('grapefruit', 'lemon'): print("That's a weird favorite fruit to have.") ``` -For more info, see here: [Python Docs - Boolean Operations](https://docs.python.org/3/reference/expressions.html#boolean-operations) -- cgit v1.2.3 From 6e48d666b31d13d801c394b527ce545b039b478f Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 14 Jul 2020 17:28:53 +0200 Subject: Incidents: link `proxy_url` if attachment fails to download Suggested by Mark during review. If the download fails, we fallback on showing an informative message, which will link the attachment cdn link. The attachment-handling logic was moved from the `archive` coroutine into `make_embed`, which now also returns the file, if available. In the end, this appears to be the smoothest approach. Co-authored-by: MarkKoz --- bot/cogs/moderation/incidents.py | 36 +++++++++----- tests/bot/cogs/moderation/test_incidents.py | 73 ++++++++++++++--------------- 2 files changed, 59 insertions(+), 50 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 65b0e458e..018538040 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -41,6 +41,10 @@ ALLOWED_ROLES: t.Set[int] = set(Guild.moderation_roles) # Message must have all of these emoji to pass the `has_signals` check ALL_SIGNALS: t.Set[str] = {signal.value for signal in Signal} +# An embed coupled with an optional file to be dispatched +# If the file is not None, the embed attempts to show it in its body +FileEmbed = t.Tuple[discord.Embed, t.Optional[discord.File]] + async def download_file(attachment: discord.Attachment) -> t.Optional[discord.File]: """ @@ -55,7 +59,7 @@ async def download_file(attachment: discord.Attachment) -> t.Optional[discord.Fi log.exception("Failed to download attachment") -def make_embed(incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> discord.Embed: +async def make_embed(incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> FileEmbed: """ Create an embed representation of `incident` for the #incidents-archive channel. @@ -66,6 +70,11 @@ def make_embed(incident: discord.Message, outcome: Signal, actioned_by: discord. of information will be relayed in other ways, e.g. webhook username. As mentions in embeds do not ping, we do not need to use `incident.clean_content`. + + If `incident` contains attachments, the first attachment will be downloaded and + returned alongside the embed. The embed attempts to display the attachment. + Should the download fail, we fallback on linking the `proxy_url`, which should + remain functional for some time after the original message is deleted. """ log.trace(f"Creating embed for {incident.id=}") @@ -83,7 +92,18 @@ def make_embed(incident: discord.Message, outcome: Signal, actioned_by: discord. ) embed.set_footer(text=footer, icon_url=actioned_by.avatar_url) - return embed + if incident.attachments: + attachment = incident.attachments[0] # User-sent messages can only contain one attachment + file = await download_file(attachment) + + if file is not None: + embed.set_image(url=f"attachment://{attachment.filename}") # Embed displays the attached file + else: + embed.set_author(name="[Failed to relay attachment]", url=attachment.proxy_url) # Embed links the file + else: + file = None + + return embed, file def is_incident(message: discord.Message) -> bool: @@ -215,17 +235,7 @@ class Incidents(Cog): message is not safe to be deleted, as we will lose some information. """ log.debug(f"Archiving incident: {incident.id} (outcome: {outcome}, actioned by: {actioned_by})") - embed = make_embed(incident, outcome, actioned_by) - - # If the incident had an attachment, we will try to relay it - if incident.attachments: - attachment = incident.attachments[0] # User-sent messages can only contain one attachment - log.debug(f"Attempting to archive incident attachment: {attachment.filename}") - - attachment_file = await attachment.to_file() # The file will be sent with the webhook - embed.set_image(url=f"attachment://{attachment.filename}") # Embed displays the attached file - else: - attachment_file = None + embed, attachment_file = await make_embed(incident, outcome, actioned_by) try: webhook = await self.bot.fetch_webhook(Webhooks.incidents_archive) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 273916199..9b6054f55 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -89,30 +89,58 @@ class TestDownloadFile(unittest.IsolatedAsyncioTestCase): self.assertIsNone(acquired_file) -class TestMakeEmbed(unittest.TestCase): +class TestMakeEmbed(unittest.IsolatedAsyncioTestCase): """Collection of tests for the `make_embed` helper function.""" - def test_make_embed_actioned(self): + async def test_make_embed_actioned(self): """Embed is coloured green and footer contains 'Actioned' when `outcome=Signal.ACTIONED`.""" - embed = incidents.make_embed(MockMessage(), incidents.Signal.ACTIONED, MockMember()) + embed, file = await incidents.make_embed(MockMessage(), incidents.Signal.ACTIONED, MockMember()) self.assertEqual(embed.colour.value, Colours.soft_green) self.assertIn("Actioned", embed.footer.text) - def test_make_embed_not_actioned(self): + async def test_make_embed_not_actioned(self): """Embed is coloured red and footer contains 'Rejected' when `outcome=Signal.NOT_ACTIONED`.""" - embed = incidents.make_embed(MockMessage(), incidents.Signal.NOT_ACTIONED, MockMember()) + embed, file = await incidents.make_embed(MockMessage(), incidents.Signal.NOT_ACTIONED, MockMember()) self.assertEqual(embed.colour.value, Colours.soft_red) self.assertIn("Rejected", embed.footer.text) - def test_make_embed_content(self): + async def test_make_embed_content(self): """Incident content appears as embed description.""" incident = MockMessage(content="this is an incident") - embed = incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember()) + embed, file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember()) self.assertEqual(incident.content, embed.description) + async def test_make_embed_with_attachment_succeeds(self): + """Incident's attachment is downloaded and displayed in the embed's image field.""" + file = MagicMock(discord.File, filename="bigbadjoe.jpg") + attachment = MockAttachment(filename="bigbadjoe.jpg") + incident = MockMessage(content="this is an incident", attachments=[attachment]) + + # Patch `download_file` to return our `file` + with patch("bot.cogs.moderation.incidents.download_file", AsyncMock(return_value=file)): + embed, returned_file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember()) + + self.assertIs(file, returned_file) + self.assertEqual("attachment://bigbadjoe.jpg", embed.image.url) + + async def test_make_embed_with_attachment_fails(self): + """Incident's attachment fails to download, proxy url is linked instead.""" + attachment = MockAttachment(proxy_url="discord.com/bigbadjoe.jpg") + incident = MockMessage(content="this is an incident", attachments=[attachment]) + + # Patch `download_file` to return None as if the download failed + with patch("bot.cogs.moderation.incidents.download_file", AsyncMock(return_value=None)): + embed, returned_file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember()) + + self.assertIsNone(returned_file) + + # The author name field is simply expected to have something in it, we do not assert the message + self.assertGreater(len(embed.author.name), 0) + self.assertEqual(embed.author.url, "discord.com/bigbadjoe.jpg") # However, it should link the exact url + @patch("bot.constants.Channels.incidents", 123) class TestIsIncident(unittest.TestCase): @@ -343,11 +371,10 @@ class TestArchive(TestIncidents): content="this is an incident", author=MockUser(name="author_name", avatar_url="author_avatar"), id=123, - attachments=[], # This incident has no attachments ) built_embed = MagicMock(discord.Embed, id=123) # We patch `make_embed` to return this - with patch("bot.cogs.moderation.incidents.make_embed", MagicMock(return_value=built_embed)): + with patch("bot.cogs.moderation.incidents.make_embed", AsyncMock(return_value=(built_embed, None))): archive_return = await self.cog_instance.archive(incident, MagicMock(value="A"), MockMember()) # Now we check that the webhook was given the correct args, and that `archive` returned True @@ -359,34 +386,6 @@ class TestArchive(TestIncidents): ) self.assertTrue(archive_return) - async def test_archive_relays_incident_with_attachments(self): - """ - Incident attachments are relayed and displayed in the embed. - - This test asserts the two things that need to happen in order to relay the attachment. - The embed returned by `make_embed` must have the `set_image` method called with the - attachment's filename, and the file must be passed to the webhook's send method. - """ - attachment_file = MagicMock(discord.File) - attachment = MagicMock( - discord.Attachment, - filename="abc.png", - to_file=AsyncMock(return_value=attachment_file), - ) - incident = MockMessage( - attachments=[attachment], - ) - built_embed = MagicMock(discord.Embed) - - with patch("bot.cogs.moderation.incidents.make_embed", MagicMock(return_value=built_embed)): - await self.cog_instance.archive(incident, incidents.Signal.ACTIONED, actioned_by=MockMember()) - - built_embed.set_image.assert_called_once_with(url="attachment://abc.png") - - send_kwargs = self.cog_instance.bot.fetch_webhook.return_value.send.call_args.kwargs - self.assertIn("file", send_kwargs) - self.assertIs(send_kwargs["file"], attachment_file) - async def test_archive_clyde_username(self): """ The archive webhook username is cleansed using `sub_clyde`. -- cgit v1.2.3 From 4fc0971dfff6a72c322a8f434a4b656cbea8fb66 Mon Sep 17 00:00:00 2001 From: ItsCinnabar <50111163+ItsCinnabar@users.noreply.github.com> Date: Tue, 14 Jul 2020 16:59:19 +0000 Subject: Update bot/resources/tags/or-gotcha.md Co-authored-by: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> --- bot/resources/tags/or-gotcha.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/or-gotcha.md b/bot/resources/tags/or-gotcha.md index 00c8a5645..d75a73d78 100644 --- a/bot/resources/tags/or-gotcha.md +++ b/bot/resources/tags/or-gotcha.md @@ -3,7 +3,7 @@ When checking if something is equal to one thing or another, you might think tha if favorite_fruit == 'grapefruit' or 'lemon': print("That's a weird favorite fruit to have.") ``` -While this makes sense in English, it may not behave the way you would expect. [In Python, you should have _complete instructions on both sides of the logical operator_.](https://docs.python.org/3/reference/expressions.html#boolean-operations) +While this makes sense in English, it may not behave the way you would expect. In Python, you should have _[complete instructions on both sides of the logical operator](https://docs.python.org/3/reference/expressions.html#boolean-operations)_. So, if you want to check if something is equal to one thing or another, there are two common ways: ```py -- cgit v1.2.3 From 042f472ac3207ad685a5acb659a5a69f22c72282 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 15 Jul 2020 01:09:35 +0200 Subject: Remove caching of last_dm_user. If you're typing up a reply and the bot gets another DM while you're typing, you might accidentally send your reply to the wrong person. This could happen even if you're very attentive, because it might be a matter of milliseconds. The complexity to prevent this isn't worth the convenience of the feature, and it's nice to get rid of the caching as well, so I've decided to just make .reply require a user for every reply. https://github.com/python-discord/bot/issues/1041 --- bot/cogs/dm_relay.py | 42 +++++++++++++++++------------------------- bot/constants.py | 2 ++ config-default.yml | 1 + 3 files changed, 20 insertions(+), 25 deletions(-) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index 3fce52b93..f62d6105e 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -1,5 +1,4 @@ import logging -from typing import Optional import discord from discord import Color @@ -8,9 +7,7 @@ from discord.ext.commands import Cog from bot import constants from bot.bot import Bot -from bot.constants import MODERATION_ROLES -from bot.utils import RedisCache -from bot.utils.checks import with_role_check +from bot.utils.checks import in_whitelist_check, with_role_check from bot.utils.messages import send_attachments from bot.utils.webhooks import send_webhook @@ -20,9 +17,6 @@ log = logging.getLogger(__name__) class DMRelay(Cog): """Relay direct messages to and from the bot.""" - # RedisCache[str, t.Union[discord.User.id, discord.Member.id]] - dm_cache = RedisCache() - def __init__(self, bot: Bot): self.bot = bot self.webhook_id = constants.Webhooks.dm_log @@ -30,11 +24,11 @@ class DMRelay(Cog): self.bot.loop.create_task(self.fetch_webhook()) @commands.command(aliases=("reply",)) - async def send_dm(self, ctx: commands.Context, member: Optional[discord.Member], *, message: str) -> None: + async def send_dm(self, ctx: commands.Context, member: discord.Member, *, message: str) -> None: """ Allows you to send a DM to a user from the bot. - If `member` is not provided, it will send to the last user who DM'd the bot. + A `member` must be provided. This feature should be used extremely sparingly. Use ModMail if you need to have a serious conversation with a user. This is just for responding to extraordinary DMs, having a little @@ -42,21 +36,11 @@ class DMRelay(Cog): NOTE: This feature will be removed if it is overused. """ - user_id = await self.dm_cache.get("last_user") - last_dm_user = ctx.guild.get_member(user_id) if user_id else None - try: - if member: - await member.send(message) - await ctx.message.add_reaction("✅") - return - elif last_dm_user: - await last_dm_user.send(message) - await ctx.message.add_reaction("✅") - return - else: - log.debug("This bot has never gotten a DM, or the RedisCache has been cleared.") - await ctx.message.add_reaction("❌") + await member.send(message) + await ctx.message.add_reaction("✅") + return + except discord.errors.Forbidden: log.debug("User has disabled DMs.") await ctx.message.add_reaction("❌") @@ -84,7 +68,6 @@ class DMRelay(Cog): username=message.author.display_name, avatar_url=message.author.avatar_url ) - await self.dm_cache.set("last_user", message.author.id) # Handle any attachments if message.attachments: @@ -106,7 +89,16 @@ class DMRelay(Cog): def cog_check(self, ctx: commands.Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" - return with_role_check(ctx, *MODERATION_ROLES) + checks = [ + with_role_check(ctx, *constants.MODERATION_ROLES), + in_whitelist_check( + ctx, + channels=[constants.Channels.dm_log], + redirect=None, + fail_silently=True, + ) + ] + return all(checks) def setup(bot: Bot) -> None: diff --git a/bot/constants.py b/bot/constants.py index 3f44003a8..778bc093c 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -395,6 +395,7 @@ class Channels(metaclass=YAMLGetter): dev_contrib: int dev_core: int dev_log: int + dm_log: int esoteric: int helpers: int how_to_get_help: int @@ -461,6 +462,7 @@ class Guild(metaclass=YAMLGetter): staff_channels: List[int] staff_roles: List[int] + class Keys(metaclass=YAMLGetter): section = "keys" diff --git a/config-default.yml b/config-default.yml index d12b9be27..8061e5e16 100644 --- a/config-default.yml +++ b/config-default.yml @@ -150,6 +150,7 @@ guild: mod_log: &MOD_LOG 282638479504965634 user_log: 528976905546760203 voice_log: 640292421988646961 + dm_log: 653713721625018428 # Off-topic off_topic_0: 291284109232308226 -- cgit v1.2.3 From 867b561a6ce4aff85451d00794c22e02793c8dac Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 14 Jul 2020 17:07:35 -0700 Subject: Suppress NotFound when removing help cmd reactions The message may be deleted somehow before the wait_for times out. Fixes #1050 Fixes BOT-6X --- bot/cogs/help.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 832f6ea6b..70e62d590 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -36,13 +36,12 @@ async def help_cleanup(bot: Bot, author: Member, message: Message) -> None: await message.add_reaction(DELETE_EMOJI) - try: - await bot.wait_for("reaction_add", check=check, timeout=300) - await message.delete() - except TimeoutError: - await message.remove_reaction(DELETE_EMOJI, bot.user) - except NotFound: - pass + with suppress(NotFound): + try: + await bot.wait_for("reaction_add", check=check, timeout=300) + await message.delete() + except TimeoutError: + await message.remove_reaction(DELETE_EMOJI, bot.user) class HelpQueryNotFound(ValueError): -- cgit v1.2.3 From 31726ecf6127f6f7dddcab3d16f4a40b8b990f6c Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 15 Jul 2020 02:30:00 +0200 Subject: Move general helper functions to submodule. --- bot/utils/__init__.py | 17 ++--------------- bot/utils/helpers.py | 12 ++++++++++++ 2 files changed, 14 insertions(+), 15 deletions(-) create mode 100644 bot/utils/helpers.py diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 2c8d57bd5..7c29a5981 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1,25 +1,17 @@ import logging -from abc import ABCMeta from typing import Optional from aiohttp import ClientConnectorError, ClientSession -from discord.ext.commands import CogMeta from bot.constants import URLs +from bot.utils.helpers import CogABCMeta, pad_base64 from bot.utils.redis_cache import RedisCache log = logging.getLogger(__name__) FAILED_REQUEST_ATTEMPTS = 3 - -__all__ = ['RedisCache', 'CogABCMeta', "send_to_paste_service"] - - -class CogABCMeta(CogMeta, ABCMeta): - """Metaclass for ABCs meant to be implemented as Cogs.""" - - pass +__all__ = ['RedisCache', 'CogABCMeta', "pad_base64", "send_to_paste_service"] async def send_to_paste_service(http_session: ClientSession, contents: str, *, extension: str = "") -> Optional[str]: @@ -64,8 +56,3 @@ async def send_to_paste_service(http_session: ClientSession, contents: str, *, e f"Got unexpected JSON response from paste service: {response_json}\n" f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." ) - - -def pad_base64(data: str) -> str: - """Return base64 `data` with padding characters to ensure its length is a multiple of 4.""" - return data + "=" * (-len(data) % 4) diff --git a/bot/utils/helpers.py b/bot/utils/helpers.py new file mode 100644 index 000000000..cfbf47753 --- /dev/null +++ b/bot/utils/helpers.py @@ -0,0 +1,12 @@ +from abc import ABCMeta + +from discord.ext.commands import CogMeta + + +class CogABCMeta(CogMeta, ABCMeta): + """Metaclass for ABCs meant to be implemented as Cogs.""" + + +def pad_base64(data: str) -> str: + """Return base64 `data` with padding characters to ensure its length is a multiple of 4.""" + return data + "=" * (-len(data) % 4) -- cgit v1.2.3 From 326beebe9b097731a39ecc9868e5e1f2bd762aae Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 15 Jul 2020 02:33:42 +0200 Subject: Move `send_to_paste_service` to services submodule --- bot/utils/__init__.py | 55 +-------------------------------------------------- bot/utils/services.py | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 54 deletions(-) create mode 100644 bot/utils/services.py diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 7c29a5981..a950f3524 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1,58 +1,5 @@ -import logging -from typing import Optional - -from aiohttp import ClientConnectorError, ClientSession - -from bot.constants import URLs from bot.utils.helpers import CogABCMeta, pad_base64 from bot.utils.redis_cache import RedisCache - -log = logging.getLogger(__name__) - -FAILED_REQUEST_ATTEMPTS = 3 +from bot.utils.services import send_to_paste_service __all__ = ['RedisCache', 'CogABCMeta', "pad_base64", "send_to_paste_service"] - - -async def send_to_paste_service(http_session: ClientSession, contents: str, *, extension: str = "") -> Optional[str]: - """ - Upload `contents` to the paste service. - - `http_session` should be the current running ClientSession from aiohttp - `extension` is added to the output URL - - When an error occurs, `None` is returned, otherwise the generated URL with the suffix. - """ - extension = extension and f".{extension}" - log.debug(f"Sending contents of size {len(contents.encode())} bytes to paste service.") - paste_url = URLs.paste_service.format(key="documents") - for attempt in range(1, FAILED_REQUEST_ATTEMPTS + 1): - try: - async with http_session.post(paste_url, data=contents) as response: - response_json = await response.json() - except ClientConnectorError: - log.warning( - f"Failed to connect to paste service at url {paste_url}, " - f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." - ) - continue - except Exception: - log.exception( - f"An unexpected error has occurred during handling of the request, " - f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." - ) - continue - - if "message" in response_json: - log.warning( - f"Paste service returned error {response_json['message']} with status code {response.status}, " - f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." - ) - continue - elif "key" in response_json: - log.info(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") - return URLs.paste_service.format(key=response_json['key']) + extension - log.warning( - f"Got unexpected JSON response from paste service: {response_json}\n" - f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." - ) diff --git a/bot/utils/services.py b/bot/utils/services.py new file mode 100644 index 000000000..087b9f969 --- /dev/null +++ b/bot/utils/services.py @@ -0,0 +1,54 @@ +import logging +from typing import Optional + +from aiohttp import ClientConnectorError, ClientSession + +from bot.constants import URLs + +log = logging.getLogger(__name__) + +FAILED_REQUEST_ATTEMPTS = 3 + + +async def send_to_paste_service(http_session: ClientSession, contents: str, *, extension: str = "") -> Optional[str]: + """ + Upload `contents` to the paste service. + + `http_session` should be the current running ClientSession from aiohttp + `extension` is added to the output URL + + When an error occurs, `None` is returned, otherwise the generated URL with the suffix. + """ + extension = extension and f".{extension}" + log.debug(f"Sending contents of size {len(contents.encode())} bytes to paste service.") + paste_url = URLs.paste_service.format(key="documents") + for attempt in range(1, FAILED_REQUEST_ATTEMPTS + 1): + try: + async with http_session.post(paste_url, data=contents) as response: + response_json = await response.json() + except ClientConnectorError: + log.warning( + f"Failed to connect to paste service at url {paste_url}, " + f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." + ) + continue + except Exception: + log.exception( + f"An unexpected error has occurred during handling of the request, " + f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." + ) + continue + + if "message" in response_json: + log.warning( + f"Paste service returned error {response_json['message']} with status code {response.status}, " + f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." + ) + continue + elif "key" in response_json: + log.info(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") + return URLs.paste_service.format(key=response_json['key']) + extension + log.warning( + f"Got unexpected JSON response from paste service: {response_json}\n" + f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." + ) -- cgit v1.2.3 From 2d1877cfb70304ff8d6bd24059459fa514d49e71 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 15 Jul 2020 02:37:28 +0200 Subject: Move `find_nth_occurrence` to utils helpers --- bot/cogs/eval.py | 12 +----------- bot/utils/__init__.py | 4 ++-- bot/utils/helpers.py | 11 +++++++++++ 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 52f7ffca7..23e5998d8 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -15,7 +15,7 @@ from bot.bot import Bot from bot.constants import Roles from bot.decorators import with_role from bot.interpreter import Interpreter -from bot.utils import send_to_paste_service +from bot.utils import find_nth_occurrence, send_to_paste_service log = logging.getLogger(__name__) @@ -222,16 +222,6 @@ async def func(): # (None,) -> Any await self._eval(ctx, code) -def find_nth_occurrence(string: str, substring: str, n: int) -> Optional[int]: - """Return index of `n`th occurrence of `substring` in `string`, or None if not found.""" - index = 0 - for _ in range(n): - index = string.find(substring, index+1) - if index == -1: - return None - return index - - def setup(bot: Bot) -> None: """Load the CodeEval cog.""" bot.add_cog(CodeEval(bot)) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index a950f3524..3e93fcb06 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1,5 +1,5 @@ -from bot.utils.helpers import CogABCMeta, pad_base64 +from bot.utils.helpers import CogABCMeta, find_nth_occurrence, pad_base64 from bot.utils.redis_cache import RedisCache from bot.utils.services import send_to_paste_service -__all__ = ['RedisCache', 'CogABCMeta', "pad_base64", "send_to_paste_service"] +__all__ = ['RedisCache', 'CogABCMeta', 'find_nth_occurrence', 'pad_base64', 'send_to_paste_service'] diff --git a/bot/utils/helpers.py b/bot/utils/helpers.py index cfbf47753..d9b60af07 100644 --- a/bot/utils/helpers.py +++ b/bot/utils/helpers.py @@ -1,4 +1,5 @@ from abc import ABCMeta +from typing import Optional from discord.ext.commands import CogMeta @@ -7,6 +8,16 @@ class CogABCMeta(CogMeta, ABCMeta): """Metaclass for ABCs meant to be implemented as Cogs.""" +def find_nth_occurrence(string: str, substring: str, n: int) -> Optional[int]: + """Return index of `n`th occurrence of `substring` in `string`, or None if not found.""" + index = 0 + for _ in range(n): + index = string.find(substring, index+1) + if index == -1: + return None + return index + + def pad_base64(data: str) -> str: """Return base64 `data` with padding characters to ensure its length is a multiple of 4.""" return data + "=" * (-len(data) % 4) -- cgit v1.2.3 From c115dcfb72e4d4a86b66bb84a72984705a2afcd4 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 15 Jul 2020 02:45:31 +0200 Subject: Change tests to work with the new file layout. 326beebe9b097731a39ecc9868e5e1f2bd762aae --- tests/bot/utils/test_init.py | 74 ---------------------------------------- tests/bot/utils/test_services.py | 74 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 74 deletions(-) delete mode 100644 tests/bot/utils/test_init.py create mode 100644 tests/bot/utils/test_services.py diff --git a/tests/bot/utils/test_init.py b/tests/bot/utils/test_init.py deleted file mode 100644 index f3a8f5939..000000000 --- a/tests/bot/utils/test_init.py +++ /dev/null @@ -1,74 +0,0 @@ -import logging -import unittest -from unittest.mock import AsyncMock, MagicMock, Mock, patch - -from aiohttp import ClientConnectorError - -from bot.utils import FAILED_REQUEST_ATTEMPTS, send_to_paste_service - - -class PasteTests(unittest.IsolatedAsyncioTestCase): - def setUp(self) -> None: - self.http_session = MagicMock() - - @patch("bot.utils.URLs.paste_service", "https://paste_service.com/{key}") - async def test_url_and_sent_contents(self): - """Correct url was used and post was called with expected data.""" - response = MagicMock( - json=AsyncMock(return_value={"key": ""}) - ) - self.http_session.post().__aenter__.return_value = response - self.http_session.post.reset_mock() - await send_to_paste_service(self.http_session, "Content") - self.http_session.post.assert_called_once_with("https://paste_service.com/documents", data="Content") - - @patch("bot.utils.URLs.paste_service", "https://paste_service.com/{key}") - async def test_paste_returns_correct_url_on_success(self): - """Url with specified extension is returned on successful requests.""" - key = "paste_key" - test_cases = ( - (f"https://paste_service.com/{key}.txt", "txt"), - (f"https://paste_service.com/{key}.py", "py"), - (f"https://paste_service.com/{key}", ""), - ) - response = MagicMock( - json=AsyncMock(return_value={"key": key}) - ) - self.http_session.post().__aenter__.return_value = response - - for expected_output, extension in test_cases: - with self.subTest(msg=f"Send contents with extension {repr(extension)}"): - self.assertEqual( - await send_to_paste_service(self.http_session, "", extension=extension), - expected_output - ) - - async def test_request_repeated_on_json_errors(self): - """Json with error message and invalid json are handled as errors and requests repeated.""" - test_cases = ({"message": "error"}, {"unexpected_key": None}, {}) - self.http_session.post().__aenter__.return_value = response = MagicMock() - self.http_session.post.reset_mock() - - for error_json in test_cases: - with self.subTest(error_json=error_json): - response.json = AsyncMock(return_value=error_json) - result = await send_to_paste_service(self.http_session, "") - self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) - self.assertIsNone(result) - - self.http_session.post.reset_mock() - - async def test_request_repeated_on_connection_errors(self): - """Requests are repeated in the case of connection errors.""" - self.http_session.post = MagicMock(side_effect=ClientConnectorError(Mock(), Mock())) - result = await send_to_paste_service(self.http_session, "") - self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) - self.assertIsNone(result) - - async def test_general_error_handled_and_request_repeated(self): - """All `Exception`s are handled, logged and request repeated.""" - self.http_session.post = MagicMock(side_effect=Exception) - result = await send_to_paste_service(self.http_session, "") - self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) - self.assertLogs("bot.utils", logging.ERROR) - self.assertIsNone(result) diff --git a/tests/bot/utils/test_services.py b/tests/bot/utils/test_services.py new file mode 100644 index 000000000..5e0855704 --- /dev/null +++ b/tests/bot/utils/test_services.py @@ -0,0 +1,74 @@ +import logging +import unittest +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +from aiohttp import ClientConnectorError + +from bot.utils.services import FAILED_REQUEST_ATTEMPTS, send_to_paste_service + + +class PasteTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.http_session = MagicMock() + + @patch("bot.utils.services.URLs.paste_service", "https://paste_service.com/{key}") + async def test_url_and_sent_contents(self): + """Correct url was used and post was called with expected data.""" + response = MagicMock( + json=AsyncMock(return_value={"key": ""}) + ) + self.http_session.post().__aenter__.return_value = response + self.http_session.post.reset_mock() + await send_to_paste_service(self.http_session, "Content") + self.http_session.post.assert_called_once_with("https://paste_service.com/documents", data="Content") + + @patch("bot.utils.services.URLs.paste_service", "https://paste_service.com/{key}") + async def test_paste_returns_correct_url_on_success(self): + """Url with specified extension is returned on successful requests.""" + key = "paste_key" + test_cases = ( + (f"https://paste_service.com/{key}.txt", "txt"), + (f"https://paste_service.com/{key}.py", "py"), + (f"https://paste_service.com/{key}", ""), + ) + response = MagicMock( + json=AsyncMock(return_value={"key": key}) + ) + self.http_session.post().__aenter__.return_value = response + + for expected_output, extension in test_cases: + with self.subTest(msg=f"Send contents with extension {repr(extension)}"): + self.assertEqual( + await send_to_paste_service(self.http_session, "", extension=extension), + expected_output + ) + + async def test_request_repeated_on_json_errors(self): + """Json with error message and invalid json are handled as errors and requests repeated.""" + test_cases = ({"message": "error"}, {"unexpected_key": None}, {}) + self.http_session.post().__aenter__.return_value = response = MagicMock() + self.http_session.post.reset_mock() + + for error_json in test_cases: + with self.subTest(error_json=error_json): + response.json = AsyncMock(return_value=error_json) + result = await send_to_paste_service(self.http_session, "") + self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) + self.assertIsNone(result) + + self.http_session.post.reset_mock() + + async def test_request_repeated_on_connection_errors(self): + """Requests are repeated in the case of connection errors.""" + self.http_session.post = MagicMock(side_effect=ClientConnectorError(Mock(), Mock())) + result = await send_to_paste_service(self.http_session, "") + self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) + self.assertIsNone(result) + + async def test_general_error_handled_and_request_repeated(self): + """All `Exception`s are handled, logged and request repeated.""" + self.http_session.post = MagicMock(side_effect=Exception) + result = await send_to_paste_service(self.http_session, "") + self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) + self.assertLogs("bot.utils", logging.ERROR) + self.assertIsNone(result) -- cgit v1.2.3 From 992f3c47d328821bcf647df7683fd5ca8bd780aa Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 14 Jul 2020 18:20:52 -0700 Subject: HelpChannels: remove cooldown info from available message Users can no longer see available channels if they're on cooldown. They will instead see a special "cooldown" channel which will explain what's going on. --- bot/cogs/help_channels.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 4d0c534b0..0c8cbb417 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -34,9 +34,6 @@ and will be yours until it has been inactive for {constants.HelpChannels.idle_mi is closed manually with `!close`. When that happens, it will be set to **dormant** and moved into \ the **Help: Dormant** category. -You may claim a new channel once every {constants.HelpChannels.claim_minutes} minutes. If you \ -currently cannot send a message in this channel, it means you are on cooldown and need to wait. - Try to write the best question you can by providing a detailed description and telling us what \ you've tried already. For more information on asking a good question, \ check out our guide on [asking good questions]({ASKING_GUIDE_URL}). -- cgit v1.2.3 From e46385d656129e06dd267764811d10ef5e8cd5a2 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Wed, 15 Jul 2020 11:06:03 +0800 Subject: Document new kwarg in docstring --- bot/cogs/watchchannels/watchchannel.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 2992a3085..044077350 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -293,6 +293,8 @@ class WatchChannel(metaclass=CogABCMeta): """ Gives an overview of the watched user list for this channel. + The optional kwarg `oldest_first` orders the list by oldest entry. + The optional kwarg `update_cache` specifies whether the cache should be refreshed by polling the API. """ -- cgit v1.2.3 From eec57c86999bb2e9486dd6443b44cfd29026c823 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Wed, 15 Jul 2020 11:39:17 +0800 Subject: Pass processed string to `extractBests` Fixes a regression where the string to be matched was not processed beforehand. --- bot/cogs/help.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 198e88b55..5f3fc4750 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -150,8 +150,8 @@ class CustomHelpCommand(HelpCommand): # Run fuzzywuzzy's processor beforehand, and avoid matching if processed string is empty # This avoids fuzzywuzzy from raising a warning on inputs with only non-alphanumeric characters - if full_process(string): - result = process.extractBests(string, choices, scorer=fuzz.ratio, score_cutoff=60, processor=None) + if (processed := full_process(string)): + result = process.extractBests(processed, choices, scorer=fuzz.ratio, score_cutoff=60, processor=None) else: result = [] -- cgit v1.2.3 From 6ccbb944a50058cea74bfdfe855a538b09ab67b7 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 15 Jul 2020 07:22:59 +0200 Subject: Restore DM user caching. This reverts commit 042f472a --- bot/cogs/dm_relay.py | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index f62d6105e..9a68b5341 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -1,4 +1,5 @@ import logging +from typing import Optional import discord from discord import Color @@ -7,6 +8,7 @@ from discord.ext.commands import Cog from bot import constants from bot.bot import Bot +from bot.utils import RedisCache from bot.utils.checks import in_whitelist_check, with_role_check from bot.utils.messages import send_attachments from bot.utils.webhooks import send_webhook @@ -17,6 +19,9 @@ log = logging.getLogger(__name__) class DMRelay(Cog): """Relay direct messages to and from the bot.""" + # RedisCache[str, t.Union[discord.User.id, discord.Member.id]] + dm_cache = RedisCache() + def __init__(self, bot: Bot): self.bot = bot self.webhook_id = constants.Webhooks.dm_log @@ -24,11 +29,11 @@ class DMRelay(Cog): self.bot.loop.create_task(self.fetch_webhook()) @commands.command(aliases=("reply",)) - async def send_dm(self, ctx: commands.Context, member: discord.Member, *, message: str) -> None: + async def send_dm(self, ctx: commands.Context, member: Optional[discord.Member], *, message: str) -> None: """ Allows you to send a DM to a user from the bot. - A `member` must be provided. + If `member` is not provided, it will send to the last user who DM'd the bot. This feature should be used extremely sparingly. Use ModMail if you need to have a serious conversation with a user. This is just for responding to extraordinary DMs, having a little @@ -36,11 +41,21 @@ class DMRelay(Cog): NOTE: This feature will be removed if it is overused. """ - try: - await member.send(message) - await ctx.message.add_reaction("✅") - return + user_id = await self.dm_cache.get("last_user") + last_dm_user = ctx.guild.get_member(user_id) if user_id else None + try: + if member: + await member.send(message) + await ctx.message.add_reaction("✅") + return + elif last_dm_user: + await last_dm_user.send(message) + await ctx.message.add_reaction("✅") + return + else: + log.debug("This bot has never gotten a DM, or the RedisCache has been cleared.") + await ctx.message.add_reaction("❌") except discord.errors.Forbidden: log.debug("User has disabled DMs.") await ctx.message.add_reaction("❌") @@ -68,6 +83,7 @@ class DMRelay(Cog): username=message.author.display_name, avatar_url=message.author.avatar_url ) + await self.dm_cache.set("last_user", message.author.id) # Handle any attachments if message.attachments: -- cgit v1.2.3 From 226eb68a4d397c14c68566f60a2de4a3704cf696 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 15 Jul 2020 07:30:02 +0200 Subject: Add the user ID to the username in dm relays. Without this, it is difficult to know precisely who the user that is DMing us is, which might be useful to us. https://github.com/python-discord/bot/issues/1041 --- bot/cogs/dm_relay.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index 9a68b5341..d3637d34b 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -80,7 +80,7 @@ class DMRelay(Cog): await send_webhook( webhook=self.webhook, content=message.clean_content, - username=message.author.display_name, + username=f"{message.author.display_name} ({message.author.id})", avatar_url=message.author.avatar_url ) await self.dm_cache.set("last_user", message.author.id) -- cgit v1.2.3 From ed6d848e3cd5cf355b9702e9c8df08c063c11a47 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 15 Jul 2020 07:33:48 +0200 Subject: Add some stats for DMs sent and received. https://github.com/python-discord/bot/issues/1041 --- bot/cogs/dm_relay.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index d3637d34b..edfcccf6d 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -48,10 +48,12 @@ class DMRelay(Cog): if member: await member.send(message) await ctx.message.add_reaction("✅") + self.bot.stats.incr("dm_relay.dm_sent") return elif last_dm_user: await last_dm_user.send(message) await ctx.message.add_reaction("✅") + self.bot.stats.incr("dm_relay.dm_sent") return else: log.debug("This bot has never gotten a DM, or the RedisCache has been cleared.") @@ -84,6 +86,7 @@ class DMRelay(Cog): avatar_url=message.author.avatar_url ) await self.dm_cache.set("last_user", message.author.id) + self.bot.stats.incr("dm_relay.dm_received") # Handle any attachments if message.attachments: -- cgit v1.2.3 From 90d2a77becb39d6b3c0056ea7c05b4a9e4d16f50 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 15 Jul 2020 10:15:08 +0200 Subject: Ves' refactor Co-authored-by: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> --- bot/cogs/dm_relay.py | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index edfcccf6d..0c3eddf42 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -41,23 +41,24 @@ class DMRelay(Cog): NOTE: This feature will be removed if it is overused. """ - user_id = await self.dm_cache.get("last_user") - last_dm_user = ctx.guild.get_member(user_id) if user_id else None + if not member: + user_id = await self.dm_cache.get("last_user") + member = ctx.guild.get_member(user_id) if user_id else None + + # If we still don't have a Member at this point, give up + if not member: + log.debug("This bot has never gotten a DM, or the RedisCache has been cleared.") + await ctx.message.add_reaction("❌") + return try: - if member: - await member.send(message) - await ctx.message.add_reaction("✅") - self.bot.stats.incr("dm_relay.dm_sent") - return - elif last_dm_user: - await last_dm_user.send(message) - await ctx.message.add_reaction("✅") - self.bot.stats.incr("dm_relay.dm_sent") - return - else: - log.debug("This bot has never gotten a DM, or the RedisCache has been cleared.") - await ctx.message.add_reaction("❌") + await member.send(message) + except discord.errors.Forbidden: + log.debug("User has disabled DMs.") + await ctx.message.add_reaction("❌") + else: + await ctx.message.add_reaction("✅") + self.bot.stats.incr("dm_relay.dm_sent") except discord.errors.Forbidden: log.debug("User has disabled DMs.") await ctx.message.add_reaction("❌") -- cgit v1.2.3 From 403572b83cf3faea9068a25cb09e809d993c1514 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 15 Jul 2020 10:39:07 +0200 Subject: Create a UserMentionOrID converter. When we're using the !reply command, using a regular UserConverter is somewhat problematic. For example, if I wanted to send the message "lemon loves you", then I'd try to write `!reply lemon loves you` - however, the optional User converter would then try to convert `lemon` into a User, which it would successfully do since there's like 60 lemons on our server. As a result, the message "loves you" would be sent to a user called lemon.. god knows which one. To solve this bit of ambiguity, I introduce a new converter which only converts user mentions or user IDs into User, not strings that may be intended as part of the message you are sending. https://github.com/python-discord/bot/issues/1041 --- bot/cogs/dm_relay.py | 3 ++- bot/converters.py | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index 0c3eddf42..c5a3dba22 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -8,6 +8,7 @@ from discord.ext.commands import Cog from bot import constants from bot.bot import Bot +from bot.converters import UserMentionOrID from bot.utils import RedisCache from bot.utils.checks import in_whitelist_check, with_role_check from bot.utils.messages import send_attachments @@ -29,7 +30,7 @@ class DMRelay(Cog): self.bot.loop.create_task(self.fetch_webhook()) @commands.command(aliases=("reply",)) - async def send_dm(self, ctx: commands.Context, member: Optional[discord.Member], *, message: str) -> None: + async def send_dm(self, ctx: commands.Context, member: Optional[UserMentionOrID], *, message: str) -> None: """ Allows you to send a DM to a user from the bot. diff --git a/bot/converters.py b/bot/converters.py index 898822165..7c62f92dd 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -330,6 +330,28 @@ def proxy_user(user_id: str) -> discord.Object: return user +class UserMentionOrID(UserConverter): + """ + Converts to a `discord.User`, but only if a mention or userID is provided. + + Unlike the default `UserConverter`, it does allow conversion from name, or name#descrim. + + This is useful in cases where that lookup strategy would lead to ambiguity. + """ + + async def convert(self, ctx: Context, argument: str) -> discord.User: + """Convert the `arg` to a `discord.User`.""" + print(argument) + match = self._get_id_match(argument) or re.match(r'<@!?([0-9]+)>$', argument) + + print(match) + + if match is not None: + return await super().convert(ctx, argument) + else: + raise BadArgument(f"`{argument}` is not a User mention or a User ID.") + + class FetchedUser(UserConverter): """ Converts to a `discord.User` or, if it fails, a `discord.Object`. -- cgit v1.2.3 From 80c1dbb240b744ceed5d1ea56c44c91d0014c304 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 15 Jul 2020 10:44:11 +0200 Subject: How did that except except block get in? Weird. https://github.com/python-discord/bot/issues/1041 --- bot/cogs/dm_relay.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index c5a3dba22..0dc15d4b1 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -60,9 +60,6 @@ class DMRelay(Cog): else: await ctx.message.add_reaction("✅") self.bot.stats.incr("dm_relay.dm_sent") - except discord.errors.Forbidden: - log.debug("User has disabled DMs.") - await ctx.message.add_reaction("❌") async def fetch_webhook(self) -> None: """Fetches the webhook object, so we can post to it.""" -- cgit v1.2.3 From 14141a25bb87c298afae89886cb0ca3df65b9dee Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 15 Jul 2020 12:59:48 +0200 Subject: Oops, these prints shouldn't be here. https://github.com/python-discord/bot/issues/1041 --- bot/converters.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 7c62f92dd..4a0633951 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -341,11 +341,8 @@ class UserMentionOrID(UserConverter): async def convert(self, ctx: Context, argument: str) -> discord.User: """Convert the `arg` to a `discord.User`.""" - print(argument) match = self._get_id_match(argument) or re.match(r'<@!?([0-9]+)>$', argument) - print(match) - if match is not None: return await super().convert(ctx, argument) else: -- cgit v1.2.3 From 281ffdfdcaa900cb82c4f0a9f0b0ae5c859a4de4 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Wed, 15 Jul 2020 18:28:03 +0200 Subject: Added command&system to purge all messages up to given message --- bot/cogs/clean.py | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index 368d91c85..7b2b83a02 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -45,6 +45,7 @@ class Clean(Cog): bots_only: bool = False, user: User = None, regex: Optional[str] = None, + until_message: Optional[Message] = None, ) -> None: """A helper function that does the actual message cleaning.""" def predicate_bots_only(message: Message) -> bool: @@ -129,6 +130,25 @@ class Clean(Cog): if not self.cleaning: return + # If we are looking for specific message. + if until_message: + # Since we will be using `delete_messages` method + # of a TextChannel + # and we need message objects to use it + # as well as to send logs + # we will start appending messages here + # instead adding them from purge. + messages.append(message) + # we could use ID's here however + # in case if the message we are looking for + # gets deleted, we won't have a way to figure that out + # thus checking for datetime should be more reliable + if message.created_at <= until_message.created_at: + # means we have found the message until which + # we were supposed to be deleting. + message_ids.append(message.id) + break + # If the message passes predicate, let's save it. if predicate is None or predicate(message): message_ids.append(message.id) @@ -138,7 +158,14 @@ class Clean(Cog): # Now let's delete the actual messages with purge. self.mod_log.ignore(Event.message_delete, *message_ids) for channel in channels: - messages += await channel.purge(limit=amount, check=predicate) + if until_message: + for i in range(0, len(messages), 100): + # while purge automatically handles the amount of messages + # delete_messages only allows for up to 100 messages at once + # thus we need to paginate the amount to always be <= 100 + await channel.delete_messages(messages[i:i + 100]) + else: + messages += await channel.purge(limit=amount, check=predicate) # Reverse the list to restore chronological order if messages: @@ -221,6 +248,17 @@ class Clean(Cog): """Delete all messages that match a certain regex, stop cleaning after traversing `amount` messages.""" await self._clean_messages(amount, ctx, regex=regex, channels=channels) + @clean_group.command(name="message", aliases=["messages"]) + @with_role(*MODERATION_ROLES) + async def clean_message(self, ctx: Context, message: Message) -> None: + """Delete all messages until certain message, stop cleaning after hitting the `message`""" + await self._clean_messages( + CleanMessages.message_limit, + ctx, + channels=[message.channel], + until_message=message + ) + @clean_group.command(name="stop", aliases=["cancel", "abort"]) @with_role(*MODERATION_ROLES) async def clean_cancel(self, ctx: Context) -> None: -- cgit v1.2.3 From ace60c4776ee02104390f0c782543118290e53c8 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Wed, 15 Jul 2020 19:04:15 +0200 Subject: Fix docstring and comments --- bot/cogs/clean.py | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index 7b2b83a02..aee7fa055 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -132,20 +132,14 @@ class Clean(Cog): # If we are looking for specific message. if until_message: - # Since we will be using `delete_messages` method - # of a TextChannel - # and we need message objects to use it - # as well as to send logs - # we will start appending messages here - # instead adding them from purge. + # Since we will be using `delete_messages` method of a TextChannel and we need message objects to + # use it as well as to send logs we will start appending messages here instead adding them from + # purge. messages.append(message) - # we could use ID's here however - # in case if the message we are looking for - # gets deleted, we won't have a way to figure that out - # thus checking for datetime should be more reliable + # we could use ID's here however in case if the message we are looking for gets deleted, + # we won't have a way to figure that out thus checking for datetime should be more reliable if message.created_at <= until_message.created_at: - # means we have found the message until which - # we were supposed to be deleting. + # means we have found the message until which we were supposed to be deleting. message_ids.append(message.id) break @@ -251,7 +245,7 @@ class Clean(Cog): @clean_group.command(name="message", aliases=["messages"]) @with_role(*MODERATION_ROLES) async def clean_message(self, ctx: Context, message: Message) -> None: - """Delete all messages until certain message, stop cleaning after hitting the `message`""" + """Delete all messages until certain message, stop cleaning after hitting the `message`.""" await self._clean_messages( CleanMessages.message_limit, ctx, -- cgit v1.2.3 From 776b4379c478284803a4a526b5f14fe63d8e7c01 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Thu, 16 Jul 2020 11:45:15 +0800 Subject: Remove duplicate reminder deletion. The function `_delete_reminder` was called twice, once in `schedule_reminder`, which calls `send_reminder`, then another in `send_reminder` itself. This led to a 404 response from the site api, as the reminder was already deleted the first time. Fixes BOT-6W --- bot/cogs/reminders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 0d20bdb2b..4f2ab1781 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -55,6 +55,7 @@ class Reminders(Cog): if remind_at < now: late = relativedelta(now, remind_at) await self.send_reminder(reminder, late) + await self._delete_reminder(reminder["id"]) else: self.schedule_reminder(reminder) @@ -157,7 +158,6 @@ class Reminders(Cog): content=user.mention, embed=embed ) - await self._delete_reminder(reminder["id"]) @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True) async def remind_group(self, ctx: Context, expiration: Duration, *, content: str) -> None: -- cgit v1.2.3 From 9389543fe89f623301842b3f850cf767d1bf45ea Mon Sep 17 00:00:00 2001 From: kosayoda Date: Thu, 16 Jul 2020 12:29:20 +0800 Subject: Extract sending error embed to a separate method. --- bot/cogs/reminders.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 4f2ab1781..ebf85cc4d 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -100,6 +100,16 @@ class Reminders(Cog): await ctx.send(embed=embed) + @staticmethod + async def _send_denial(ctx: Context, reason: str) -> None: + """Send an embed denying the user from creating a reminder.""" + embed = discord.Embed() + embed.colour = discord.Colour.red() + embed.title = random.choice(NEGATIVE_REPLIES) + embed.description = reason + + await ctx.send(embed=embed) + def schedule_reminder(self, reminder: dict) -> None: """A coroutine which sends the reminder once the time is reached, and cancels the running task.""" reminder_id = reminder["id"] @@ -171,18 +181,12 @@ class Reminders(Cog): Expiration is parsed per: http://strftime.org/ """ - embed = discord.Embed() - # If the user is not staff, we need to verify whether or not to make a reminder at all. if without_role_check(ctx, *STAFF_ROLES): # If they don't have permission to set a reminder in this channel if ctx.channel.id not in WHITELISTED_CHANNELS: - embed.colour = discord.Colour.red() - embed.title = random.choice(NEGATIVE_REPLIES) - embed.description = "Sorry, you can't do that here!" - - return await ctx.send(embed=embed) + return await self._send_denial(ctx, "Sorry, you can't do that here!") # Get their current active reminders active_reminders = await self.bot.api_client.get( @@ -195,12 +199,7 @@ class Reminders(Cog): # Let's limit this, so we don't get 10 000 # reminders from kip or something like that :P if len(active_reminders) > MAXIMUM_REMINDERS: - embed.colour = discord.Colour.red() - embed.title = random.choice(NEGATIVE_REPLIES) - embed.description = "You have too many active reminders!" - - return await ctx.send(embed=embed) - + return await self._send_denial(ctx, "You have too many active reminders!") # Now we can attempt to actually set the reminder. reminder = await self.bot.api_client.post( 'bot/reminders', -- cgit v1.2.3 From 61459ed1fd40b10eb9c61d2b2d3ae1cea3547ea8 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Thu, 16 Jul 2020 13:01:54 +0800 Subject: Add method to check if user is allowed to mention in reminders. --- bot/cogs/reminders.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index ebf85cc4d..aefc4a359 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -9,10 +9,11 @@ from operator import itemgetter import discord from dateutil.parser import isoparse from dateutil.relativedelta import relativedelta +from discord import Member, Role from discord.ext.commands import Cog, Context, group from bot.bot import Bot -from bot.constants import Guild, Icons, NEGATIVE_REPLIES, POSITIVE_REPLIES, STAFF_ROLES +from bot.constants import Guild, Icons, MODERATION_ROLES, NEGATIVE_REPLIES, POSITIVE_REPLIES, STAFF_ROLES from bot.converters import Duration from bot.pagination import LinePaginator from bot.utils.checks import without_role_check @@ -24,6 +25,8 @@ log = logging.getLogger(__name__) WHITELISTED_CHANNELS = Guild.reminder_whitelist MAXIMUM_REMINDERS = 5 +Mentionable = t.Union[Member, Role] + class Reminders(Cog): """Provide in-channel reminder functionality.""" @@ -110,6 +113,23 @@ class Reminders(Cog): await ctx.send(embed=embed) + @staticmethod + async def allow_mentions(ctx: Context, mentions: t.List[Mentionable]) -> t.Tuple[bool, str]: + """ + Returns whether or not the list of mentions is allowed. + + Conditions: + - Role reminders are Mods+ + - Reminders for other users are Helpers+ + If mentions aren't allowed, also return the type of mention(s) disallowed. + """ + if without_role_check(ctx, *STAFF_ROLES): + return False, "members/roles" + elif without_role_check(ctx, *MODERATION_ROLES): + return all(isinstance(mention, Member) for mention in mentions), "roles" + else: + return True, "" + def schedule_reminder(self, reminder: dict) -> None: """A coroutine which sends the reminder once the time is reached, and cancels the running task.""" reminder_id = reminder["id"] -- cgit v1.2.3 From 76b8e4625e853bcc96c946fa408c2267e78dbc72 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Thu, 16 Jul 2020 13:10:28 +0800 Subject: Add generator that converts IDs to Role or Member objects. --- bot/cogs/reminders.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index aefc4a359..ab47f3b11 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -130,6 +130,13 @@ class Reminders(Cog): else: return True, "" + def get_mentionables_from_ids(self, mention_ids: t.List[str]) -> t.Iterator[Mentionable]: + """Converts Role and Member ids to their corresponding objects if possible.""" + guild = self.bot.get_guild(Guild.id) + for mention_id in mention_ids: + if (mentionable := (guild.get_member(mention_id) or guild.get_role(mention_id))): + yield mentionable + def schedule_reminder(self, reminder: dict) -> None: """A coroutine which sends the reminder once the time is reached, and cancels the running task.""" reminder_id = reminder["id"] -- cgit v1.2.3 From da2849a4fbbc5b2180cc042be66f1511017b2488 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Thu, 16 Jul 2020 13:12:12 +0800 Subject: Allow mentioning other users and roles in reminders. --- bot/cogs/reminders.py | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index ab47f3b11..5ef35602c 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -10,7 +10,7 @@ import discord from dateutil.parser import isoparse from dateutil.relativedelta import relativedelta from discord import Member, Role -from discord.ext.commands import Cog, Context, group +from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot from bot.constants import Guild, Icons, MODERATION_ROLES, NEGATIVE_REPLIES, POSITIVE_REPLIES, STAFF_ROLES @@ -197,12 +197,16 @@ class Reminders(Cog): ) @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True) - async def remind_group(self, ctx: Context, expiration: Duration, *, content: str) -> None: + async def remind_group( + self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str + ) -> None: """Commands for managing your reminders.""" - await ctx.invoke(self.new_reminder, expiration=expiration, content=content) + await ctx.invoke(self.new_reminder, mentions=mentions, expiration=expiration, content=content) @remind_group.command(name="new", aliases=("add", "create")) - async def new_reminder(self, ctx: Context, expiration: Duration, *, content: str) -> t.Optional[discord.Message]: + async def new_reminder( + self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str + ) -> t.Optional[discord.Message]: """ Set yourself a simple reminder. @@ -227,6 +231,17 @@ class Reminders(Cog): # reminders from kip or something like that :P if len(active_reminders) > MAXIMUM_REMINDERS: return await self._send_denial(ctx, "You have too many active reminders!") + + # Filter mentions to see if the user can mention members/roles + if mentions: + mentions_allowed, disallowed_mentions = await self.allow_mentions(ctx, mentions) + if not mentions_allowed: + return await self._send_denial( + ctx, f"You can't mention other {disallowed_mentions} in your reminder!" + ) + + mention_ids = [mention.id for mention in mentions] + # Now we can attempt to actually set the reminder. reminder = await self.bot.api_client.post( 'bot/reminders', @@ -235,17 +250,22 @@ class Reminders(Cog): 'channel_id': ctx.message.channel.id, 'jump_url': ctx.message.jump_url, 'content': content, - 'expiration': expiration.isoformat() + 'expiration': expiration.isoformat(), + 'mentions': mention_ids, } ) now = datetime.utcnow() - timedelta(seconds=1) humanized_delta = humanize_delta(relativedelta(expiration, now)) + mention_string = ( + f"Your reminder will arrive in {humanized_delta} " + f"and will mention {len(mentions)} other(s)!" + ) # Confirm to the user that it worked. await self._send_confirmation( ctx, - on_success=f"Your reminder will arrive in {humanized_delta}!", + on_success=mention_string, reminder_id=reminder["id"], delivery_dt=expiration, ) -- cgit v1.2.3 From 1ee3febc398deafa4d87b6db93b4e3af6976b0e7 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Thu, 16 Jul 2020 13:13:05 +0800 Subject: Send additional mentions in reminders. --- bot/cogs/reminders.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 5ef35602c..a004902c2 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -191,8 +191,12 @@ class Reminders(Cog): name=f"Sorry it arrived {humanize_delta(late, max_units=2)} late!" ) + additional_mentions = ' '.join( + mentionable.mention for mentionable in self.get_mentionables_from_ids(reminder["mentions"]) + ) + await channel.send( - content=user.mention, + content=f"{user.mention} {additional_mentions}", embed=embed ) -- cgit v1.2.3 From cf9a350934a099ae71fcc0c46fbf57d7fb82dd86 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Thu, 16 Jul 2020 13:14:10 +0800 Subject: List additional mentions in `!reminder list`. --- bot/cogs/reminders.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index a004902c2..fd3c6efa2 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -290,7 +290,7 @@ class Reminders(Cog): # Make a list of tuples so it can be sorted by time. reminders = sorted( ( - (rem['content'], rem['expiration'], rem['id']) + (rem['content'], rem['expiration'], rem['id'], rem['mentions']) for rem in data ), key=itemgetter(1) @@ -298,13 +298,19 @@ class Reminders(Cog): lines = [] - for content, remind_at, id_ in reminders: + for content, remind_at, id_, mentions in reminders: # Parse and humanize the time, make it pretty :D remind_datetime = isoparse(remind_at).replace(tzinfo=None) time = humanize_delta(relativedelta(remind_datetime, now)) + mentions = ", ".join( + # Both Role and User objects have the `name` attribute + mention.name for mention in self.get_mentionables_from_ids(mentions) + ) + mention_string = f"\n**Mentions:** {mentions}" if mentions else "" + text = textwrap.dedent(f""" - **Reminder #{id_}:** *expires in {time}* (ID: {id_}) + **Reminder #{id_}:** *expires in {time}* (ID: {id_}) {mention_string} {content} """).strip() -- cgit v1.2.3 From 3f319488f479cd38e719201b4c926ace68ef9102 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Thu, 16 Jul 2020 13:14:38 +0800 Subject: Allow editing additional mentions for reminders. --- bot/cogs/reminders.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index fd3c6efa2..9eddd283b 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -384,6 +384,34 @@ class Reminders(Cog): ) await self._reschedule_reminder(reminder) + @edit_reminder_group.command(name="mentions", aliases=("pings",)) + async def edit_reminder_mentions(self, ctx: Context, id_: int, mentions: Greedy[Mentionable]) -> None: + """Edit one of your reminder's mentions.""" + # Filter mentions to see if the user can mention members/roles + mentions_allowed, disallowed_mentions = await self.allow_mentions(ctx, mentions) + if not mentions_allowed: + return await self._send_denial( + ctx, f"You can't mention other {disallowed_mentions} in your reminder!" + ) + + mention_ids = [mention.id for mention in mentions] + reminder = await self.bot.api_client.patch( + 'bot/reminders/' + str(id_), + json={"mentions": mention_ids} + ) + + # Parse the reminder expiration back into a datetime for the confirmation message + expiration = isoparse(reminder['expiration']).replace(tzinfo=None) + + # Send a confirmation message to the channel + await self._send_confirmation( + ctx, + on_success="That reminder has been edited successfully!", + reminder_id=id_, + delivery_dt=expiration, + ) + await self._reschedule_reminder(reminder) + @remind_group.command("delete", aliases=("remove", "cancel")) async def delete_reminder(self, ctx: Context, id_: int) -> None: """Delete one of your active reminders.""" -- cgit v1.2.3 From b6abe9cbb2e63f562bb44e14d51ea87f19da32ac Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Thu, 16 Jul 2020 10:41:54 +0200 Subject: Prevent deleting messages above the desired message. --- bot/cogs/clean.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index aee7fa055..f436e531a 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -132,17 +132,18 @@ class Clean(Cog): # If we are looking for specific message. if until_message: - # Since we will be using `delete_messages` method of a TextChannel and we need message objects to - # use it as well as to send logs we will start appending messages here instead adding them from - # purge. - messages.append(message) + # we could use ID's here however in case if the message we are looking for gets deleted, # we won't have a way to figure that out thus checking for datetime should be more reliable - if message.created_at <= until_message.created_at: + if message.created_at < until_message.created_at: # means we have found the message until which we were supposed to be deleting. - message_ids.append(message.id) break + # Since we will be using `delete_messages` method of a TextChannel and we need message objects to + # use it as well as to send logs we will start appending messages here instead adding them from + # purge. + messages.append(message) + # If the message passes predicate, let's save it. if predicate is None or predicate(message): message_ids.append(message.id) -- cgit v1.2.3 From 6f5fb205bcc3f9b468ef585f83e123e5b19d7340 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 16 Jul 2020 17:03:02 +0200 Subject: Incidents: reduce log level of 404 exception Co-authored-by: MarkKoz --- bot/cogs/moderation/incidents.py | 2 ++ tests/bot/cogs/moderation/test_incidents.py | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 018538040..2d5f26f20 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -55,6 +55,8 @@ async def download_file(attachment: discord.Attachment) -> t.Optional[discord.Fi log.debug(f"Attempting to download attachment: {attachment.filename}") try: return await attachment.to_file() + except discord.NotFound as not_found: + log.debug(f"Failed to download attachment: {not_found}") except Exception: log.exception("Failed to download attachment") diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 9b6054f55..435a1cd51 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -81,13 +81,23 @@ class TestDownloadFile(unittest.IsolatedAsyncioTestCase): acquired_file = await incidents.download_file(attachment) self.assertIs(file, acquired_file) - async def test_download_file_fail(self): - """If `to_file` fails, function handles the exception & returns None.""" + async def test_download_file_404(self): + """If `to_file` encounters a 404, function handles the exception & returns None.""" attachment = MockAttachment(to_file=AsyncMock(side_effect=mock_404)) acquired_file = await incidents.download_file(attachment) self.assertIsNone(acquired_file) + async def test_download_file_fail(self): + """If `to_file` fails on a non-404 error, function logs the exception & returns None.""" + arbitrary_error = discord.HTTPException(MagicMock(aiohttp.ClientResponse), "Arbitrary API error") + attachment = MockAttachment(to_file=AsyncMock(side_effect=arbitrary_error)) + + with self.assertLogs(logger=incidents.log, level=logging.ERROR): + acquired_file = await incidents.download_file(attachment) + + self.assertIsNone(acquired_file) + class TestMakeEmbed(unittest.IsolatedAsyncioTestCase): """Collection of tests for the `make_embed` helper function.""" -- cgit v1.2.3 From 4fb188da6af5a1751e7a996693001d464232b10c Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Thu, 16 Jul 2020 19:54:06 +0200 Subject: Bugfix: Show ID for embed DM relays, too. --- bot/cogs/dm_relay.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index 0dc15d4b1..0d8f340b4 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -99,7 +99,7 @@ class DMRelay(Cog): await send_webhook( webhook=self.webhook, embed=e, - username=message.author.display_name, + username=f"{message.author.display_name} ({message.author.id})", avatar_url=message.author.avatar_url ) except discord.HTTPException: -- cgit v1.2.3 From 476d4070940830d14859f7cc8970a14409d142a6 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 16 Jul 2020 20:27:32 +0200 Subject: Incidents: reduce log level of 403 exception In addition to 404, this shouldn't send Sentry notifs. Co-authored-by: MarkKoz --- bot/cogs/moderation/incidents.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 2d5f26f20..3605ab1d2 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -51,12 +51,13 @@ async def download_file(attachment: discord.Attachment) -> t.Optional[discord.Fi Download & return `attachment` file. If the download fails, the reason is logged and None will be returned. + 404 and 403 errors are only logged at debug level. """ log.debug(f"Attempting to download attachment: {attachment.filename}") try: return await attachment.to_file() - except discord.NotFound as not_found: - log.debug(f"Failed to download attachment: {not_found}") + except (discord.NotFound, discord.Forbidden) as exc: + log.debug(f"Failed to download attachment: {exc}") except Exception: log.exception("Failed to download attachment") -- cgit v1.2.3 From db79b6acb8c4204ef2dad7053d94f0ddcec3c283 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Fri, 17 Jul 2020 21:30:39 +0200 Subject: Kaizen: Move OffTopicName to converters.py. --- bot/cogs/off_topic_names.py | 31 ++----------------------------- bot/converters.py | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 29 deletions(-) diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 201579a0b..ce95450e0 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -4,46 +4,19 @@ import logging from datetime import datetime, timedelta from discord import Colour, Embed -from discord.ext.commands import BadArgument, Cog, Context, Converter, group +from discord.ext.commands import Cog, Context, group from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Channels, MODERATION_ROLES +from bot.converters import OffTopicName from bot.decorators import with_role from bot.pagination import LinePaginator - CHANNELS = (Channels.off_topic_0, Channels.off_topic_1, Channels.off_topic_2) log = logging.getLogger(__name__) -class OffTopicName(Converter): - """A converter that ensures an added off-topic name is valid.""" - - @staticmethod - async def convert(ctx: Context, argument: str) -> str: - """Attempt to replace any invalid characters with their approximate Unicode equivalent.""" - allowed_characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-" - - # Chain multiple words to a single one - argument = "-".join(argument.split()) - - if not (2 <= len(argument) <= 96): - raise BadArgument("Channel name must be between 2 and 96 chars long") - - elif not all(c.isalnum() or c in allowed_characters for c in argument): - raise BadArgument( - "Channel name must only consist of " - "alphanumeric characters, minus signs or apostrophes." - ) - - # Replace invalid characters with unicode alternatives. - table = str.maketrans( - allowed_characters, '𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹ǃ?’’-' - ) - return argument.translate(table) - - async def update_names(bot: Bot) -> None: """Background updater task that performs the daily channel name update.""" while True: diff --git a/bot/converters.py b/bot/converters.py index 4a0633951..406fd0d68 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -237,6 +237,32 @@ class Duration(DurationDelta): raise BadArgument(f"`{duration}` results in a datetime outside the supported range.") +class OffTopicName(Converter): + """A converter that ensures an added off-topic name is valid.""" + + async def convert(self, ctx: Context, argument: str) -> str: + """Attempt to replace any invalid characters with their approximate Unicode equivalent.""" + allowed_characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-" + + # Chain multiple words to a single one + argument = "-".join(argument.split()) + + if not (2 <= len(argument) <= 96): + raise BadArgument("Channel name must be between 2 and 96 chars long") + + elif not all(c.isalnum() or c in allowed_characters for c in argument): + raise BadArgument( + "Channel name must only consist of " + "alphanumeric characters, minus signs or apostrophes." + ) + + # Replace invalid characters with unicode alternatives. + table = str.maketrans( + allowed_characters, '𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹ǃ?’’-' + ) + return argument.translate(table) + + class ISODateTime(Converter): """Converts an ISO-8601 datetime string into a datetime.datetime.""" -- cgit v1.2.3 From 98c325f316038536270c87d0f767e2c18c215df7 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Fri, 17 Jul 2020 21:33:14 +0200 Subject: Cache AllowDenyList data at bot startup. We shouldn't be making an API call for every single message posted, so what we're gonna do is cache the data in the Bot, and then update the cache whenever we make changes to it via our new AllowDenyList cog. Since this cog will be the only way to make changes to this, this level of lazy caching should be enough to always keep the cache up to date. --- bot/bot.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bot/bot.py b/bot/bot.py index 313652d11..b170be6d3 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -49,6 +49,10 @@ class Bot(commands.Bot): self.stats = AsyncStatsClient(self.loop, statsd_url, 8125, prefix="bot") + async def _cache_allow_deny_list_data(self) -> None: + """Cache all the data in the AllowDenyList on the site.""" + self.allow_deny_list_cache = await self.api_client.get('bot/allow_deny_lists') + async def _create_redis_session(self) -> None: """ Create the Redis connection pool, and then open the redis event gate. @@ -159,6 +163,9 @@ class Bot(commands.Bot): self.http_session = aiohttp.ClientSession(connector=self._connector) self.api_client.recreate(force=True, connector=self._connector) + # Build the AllowDenyList cache + self.loop.create_task(self._cache_allow_deny_list_data()) + async def on_guild_available(self, guild: discord.Guild) -> None: """ Set the internal guild available event when constants.Guild.id becomes available. -- cgit v1.2.3 From d83417432324019b16d0450cdb0c71db9452c52f Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Fri, 17 Jul 2020 21:34:40 +0200 Subject: Add ValidAllowDenyListType converter. We'll use this to ensure the input is valid when people try to whitelist or blacklist stuff. It will fetch its data from an Enum maintained on the site, so that the types of lists we support will only need to be maintained in a single place, instead of duplicating that data in the bot and the site. --- bot/converters.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/bot/converters.py b/bot/converters.py index 406fd0d68..4d2acb910 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -7,7 +7,7 @@ from ssl import CertificateError import dateutil.parser import dateutil.tz import discord -from aiohttp import ClientConnectorError +from aiohttp import ClientConnectorError, ContentTypeError from dateutil.relativedelta import relativedelta from discord.ext.commands import BadArgument, Context, Converter, UserConverter @@ -34,6 +34,32 @@ def allowed_strings(*values, preserve_case: bool = False) -> t.Callable[[str], s return converter +class ValidAllowDenyListType(Converter): + """ + A converter that checks whether the given string is a valid AllowDenyList type. + + Raises `BadArgument` if the argument is not a valid AllowDenyList type, and simply + passes through the given argument otherwise. + """ + + async def convert(self, ctx: Context, list_type: str) -> str: + """Checks whether the given string is a valid AllowDenyList type.""" + try: + valid_types = await ctx.bot.api_client.get('bot/allow_deny_lists/get_types') + except ContentTypeError: + raise BadArgument("Cannot validate list_type: Unable to fetch valid types from API.") + + valid_types = [enum for enum, classname in valid_types] + list_type = list_type.upper() + + if list_type not in valid_types: + raise BadArgument( + f"You have provided an invalid AllowDenyList type!\n\n" + f"Please provide one of the following: \n{', '.join(valid_types)}." + ) + return list_type + + class ValidPythonIdentifier(Converter): """ A converter that checks whether the given string is a valid Python identifier. -- cgit v1.2.3 From 0d22a0483e619788f59b6dfe2f8e6f64ec76e326 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Fri, 17 Jul 2020 21:40:16 +0200 Subject: Kaizen: Make error_handler.py more embeddy. Currently, some types of errors are returning plain strings that repeat the input (which can be exploited to deliver stuff like mentions), and others are returning generic messages that don't give any exception information. This commit unifies our approach around putting as much information as we can (including the exception message), but always putting it inside an embed, so that stuff like pings will not fire. This, combined with the 1.4.0a `allowed_mentions` functionality, seems like a reasonable compromise between security and usability. --- bot/cogs/error_handler.py | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 233851e41..f9d4de638 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -2,12 +2,13 @@ import contextlib import logging import typing as t +from discord import Embed from discord.ext.commands import Cog, Context, errors from sentry_sdk import push_scope from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Channels +from bot.constants import Channels, Colours from bot.converters import TagNameConverter from bot.utils.checks import InWhitelistCheckFailure @@ -20,6 +21,14 @@ class ErrorHandler(Cog): def __init__(self, bot: Bot): self.bot = bot + def _get_error_embed(self, title: str, body: str) -> Embed: + """Return an embed that contains the exception.""" + return Embed( + title=title, + colour=Colours.soft_red, + description=body + ) + @Cog.listener() async def on_command_error(self, ctx: Context, e: errors.CommandError) -> None: """ @@ -162,25 +171,34 @@ class ErrorHandler(Cog): prepared_help_command = self.get_help_command(ctx) if isinstance(e, errors.MissingRequiredArgument): - await ctx.send(f"Missing required argument `{e.param.name}`.") + embed = self._get_error_embed("Missing required argument", e.param.name) + await ctx.send(embed=embed) await prepared_help_command self.bot.stats.incr("errors.missing_required_argument") elif isinstance(e, errors.TooManyArguments): - await ctx.send("Too many arguments provided.") + embed = self._get_error_embed("Too many arguments", str(e)) + await ctx.send(embed=embed) await prepared_help_command self.bot.stats.incr("errors.too_many_arguments") elif isinstance(e, errors.BadArgument): - await ctx.send("Bad argument: Please double-check your input arguments and try again.\n") + embed = self._get_error_embed("Bad argument", str(e)) + await ctx.send(embed=embed) await prepared_help_command self.bot.stats.incr("errors.bad_argument") elif isinstance(e, errors.BadUnionArgument): - await ctx.send(f"Bad argument: {e}\n```{e.errors[-1]}```") + embed = self._get_error_embed("Bad argument", f"{e}\n{e.errors[-1]}") + await ctx.send(embed=embed) self.bot.stats.incr("errors.bad_union_argument") elif isinstance(e, errors.ArgumentParsingError): - await ctx.send(f"Argument parsing error: {e}") + embed = self._get_error_embed("Argument parsing error", str(e)) + await ctx.send(embed=embed) self.bot.stats.incr("errors.argument_parsing_error") else: - await ctx.send("Something about your input seems off. Check the arguments:") + embed = self._get_error_embed( + "Input error", + "Something about your input seems off. Check the arguments and try again." + ) + await ctx.send(embed=embed) await prepared_help_command self.bot.stats.incr("errors.other_user_input_error") -- cgit v1.2.3 From ccc2e7abe8762dd394a0e548a47d881dbffdc917 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 18 Jul 2020 12:50:04 +0200 Subject: Better BadArgument exception text. --- bot/converters.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 4d2acb910..429546ba2 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -50,12 +50,13 @@ class ValidAllowDenyListType(Converter): raise BadArgument("Cannot validate list_type: Unable to fetch valid types from API.") valid_types = [enum for enum, classname in valid_types] + valid_types_lower = [type_.lower() for type_ in valid_types] list_type = list_type.upper() if list_type not in valid_types: raise BadArgument( - f"You have provided an invalid AllowDenyList type!\n\n" - f"Please provide one of the following: \n{', '.join(valid_types)}." + f"You have provided an invalid list type!\n\n" + f"Please provide one of the following: \n{', '.join(valid_types_lower)}." ) return list_type -- cgit v1.2.3 From 7d7fdd7bd27aba48edf65cb8f9da1974ea0aac0b Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 18 Jul 2020 13:44:59 +0200 Subject: Bulletlist with valid file types in converter. --- bot/converters.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 429546ba2..edac67be2 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -50,13 +50,13 @@ class ValidAllowDenyListType(Converter): raise BadArgument("Cannot validate list_type: Unable to fetch valid types from API.") valid_types = [enum for enum, classname in valid_types] - valid_types_lower = [type_.lower() for type_ in valid_types] + valid_types_list = '\n'.join([f"• {type_.lower()}" for type_ in valid_types]) list_type = list_type.upper() if list_type not in valid_types: raise BadArgument( f"You have provided an invalid list type!\n\n" - f"Please provide one of the following: \n{', '.join(valid_types_lower)}." + f"Please provide one of the following: \n{valid_types_list}" ) return list_type -- cgit v1.2.3 From b1311ea71adbc3c4c5568363aa971a08f21b2522 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 18 Jul 2020 13:46:10 +0200 Subject: Make the cache more convenient to access. Instead of just dumping the JSON response from the site, we'll build a data structure that it will be convenient to access from our new cog, and from the Filtering cog. --- bot/bot.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/bot/bot.py b/bot/bot.py index b170be6d3..6c02e72a7 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -51,7 +51,19 @@ class Bot(commands.Bot): async def _cache_allow_deny_list_data(self) -> None: """Cache all the data in the AllowDenyList on the site.""" - self.allow_deny_list_cache = await self.api_client.get('bot/allow_deny_lists') + full_cache = await self.api_client.get('bot/allow_deny_lists') + self.allow_deny_list_cache = {} + + for item in full_cache: + type_ = item.get("type") + allowed = item.get("allowed") + metadata = { + "content": item.get("content"), + "id": item.get("id"), + "created_at": item.get("created_at"), + "updated_at": item.get("updated_at"), + } + self.allow_deny_list_cache.setdefault(f"{type_}.{allowed}", []).append(metadata) async def _create_redis_session(self) -> None: """ -- cgit v1.2.3 From 4d1b6a3abee00d9729ce333a25a2440d00d509f1 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 18 Jul 2020 13:47:40 +0200 Subject: Add AllowDenyLists cog. This includes commands to add, remove and show the items in the whitelists and blacklists for the different list types. Commands are limited to Moderators+. --- bot/__main__.py | 1 + bot/cogs/allow_deny_lists.py | 144 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 bot/cogs/allow_deny_lists.py diff --git a/bot/__main__.py b/bot/__main__.py index 49388455a..932aa705c 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -53,6 +53,7 @@ bot.load_extension("bot.cogs.verification") # Feature cogs bot.load_extension("bot.cogs.alias") +bot.load_extension("bot.cogs.allow_deny_lists") bot.load_extension("bot.cogs.defcon") bot.load_extension("bot.cogs.dm_relay") bot.load_extension("bot.cogs.duck_pond") diff --git a/bot/cogs/allow_deny_lists.py b/bot/cogs/allow_deny_lists.py new file mode 100644 index 000000000..d03c774ec --- /dev/null +++ b/bot/cogs/allow_deny_lists.py @@ -0,0 +1,144 @@ +import logging + +from discord import Colour, Embed +from discord.ext.commands import BadArgument, Cog, Context, group + +from bot import constants +from bot.api import ResponseCodeError +from bot.bot import Bot +from bot.converters import ValidAllowDenyListType +from bot.pagination import LinePaginator +from bot.utils.checks import with_role_check + +log = logging.getLogger(__name__) + + +class AllowDenyLists(Cog): + """Commands for blacklisting and whitelisting things.""" + + def __init__(self, bot: Bot) -> None: + self.bot = bot + + async def _add_data(self, ctx: Context, allowed: bool, list_type: ValidAllowDenyListType, content: str) -> None: + """Add an item to an allow or denylist.""" + payload = { + 'allowed': allowed, + 'type': list_type, + 'content': content, + } + allow_type = "whitelist" if allowed else "blacklist" + + # Try to add the item to the database + try: + item = await self.bot.api_client.post( + "bot/allow_deny_lists", + json=payload + ) + except ResponseCodeError as e: + if e.status == 500: + await ctx.message.add_reaction("❌") + raise BadArgument( + f"Unable to add the item to the {allow_type}. " + "The item probably already exists. Keep in mind that a " + "blacklist and a whitelist for the same item cannot co-exist, " + "and we do not permit any duplicates." + ) + raise + + # Insert the item into the cache + type_ = item.get("type") + allowed = item.get("allowed") + metadata = { + "content": item.get("content"), + "id": item.get("id"), + "created_at": item.get("created_at"), + "updated_at": item.get("updated_at"), + } + self.bot.allow_deny_list_cache.setdefault(f"{type_}.{allowed}", []).append(metadata) + await ctx.message.add_reaction("✅") + + async def _delete_data(self, ctx: Context, allowed: bool, list_type: ValidAllowDenyListType, content: str) -> None: + """Remove an item from an allow or denylist.""" + item = None + + for allow_list in self.bot.allow_deny_list_cache.get(f"{list_type}.{allowed}", []): + if content == allow_list.get("content"): + item = allow_list + break + + if item is not None: + await self.bot.api_client.delete( + f"bot/allow_deny_lists/{item.get('id')}" + ) + self.bot.allow_deny_list_cache[f"{list_type}.{allowed}"].remove(item) + await ctx.message.add_reaction("✅") + + async def _list_all_data(self, ctx: Context, allowed: bool, list_type: ValidAllowDenyListType) -> None: + """Paginate and display all items in an allow or denylist.""" + result = self.bot.allow_deny_list_cache.get(f"{list_type}.{allowed}", []) + lines = sorted(f"• {item.get('content')}" for item in result) + allowed_string = "Whitelisted" if allowed else "Blacklisted" + embed = Embed( + title=f"{allowed_string} {list_type.lower()} items ({len(result)} total)", + colour=Colour.blue() + ) + + if result: + await LinePaginator.paginate(lines, ctx, embed, max_lines=15, empty=False) + else: + embed.description = "Hmmm, seems like there's nothing here yet." + await ctx.send(embed=embed) + + @group(aliases=("allowlist", "allow", "al", "wl")) + async def whitelist(self, ctx: Context) -> None: + """Group for whitelisting commands.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @group(aliases=("denylist", "deny", "bl", "dl")) + async def blacklist(self, ctx: Context) -> None: + """Group for blacklisting commands.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @whitelist.command(name="add", aliases=("a", "set")) + async def allow_add(self, ctx: Context, list_type: ValidAllowDenyListType, content: str) -> None: + """Add an item to the specified allowlist.""" + await self._add_data(ctx, True, list_type, content) + + @blacklist.command(name="add", aliases=("a", "set")) + async def deny_add(self, ctx: Context, list_type: ValidAllowDenyListType, content: str) -> None: + """Add an item to the specified denylist.""" + await self._add_data(ctx, False, list_type, content) + + @whitelist.command(name="remove", aliases=("delete", "rm",)) + async def allow_delete(self, ctx: Context, list_type: ValidAllowDenyListType, content: str) -> None: + """Remove an item from the specified allowlist.""" + await self._delete_data(ctx, True, list_type, content) + + @blacklist.command(name="remove", aliases=("delete", "rm",)) + async def deny_delete(self, ctx: Context, list_type: ValidAllowDenyListType, content: str) -> None: + """Remove an item from the specified denylist.""" + await self._delete_data(ctx, False, list_type, content) + + @whitelist.command(name="get", aliases=("list", "ls", "fetch", "show")) + async def allow_get(self, ctx: Context, list_type: ValidAllowDenyListType) -> None: + """Get the contents of a specified allowlist.""" + await self._list_all_data(ctx, True, list_type) + + @blacklist.command(name="get", aliases=("list", "ls", "fetch", "show")) + async def deny_get(self, ctx: Context, list_type: ValidAllowDenyListType) -> None: + """Get the contents of a specified denylist.""" + await self._list_all_data(ctx, False, list_type) + + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + checks = [ + with_role_check(ctx, *constants.MODERATION_ROLES), + ] + return all(checks) + + +def setup(bot: Bot) -> None: + """Load the AllowDenyLists cog.""" + bot.add_cog(AllowDenyLists(bot)) -- cgit v1.2.3 From 2228b4229aa2c866616e2452af2c6a2f85c21fef Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 18 Jul 2020 14:20:24 +0200 Subject: Add more logging to AllowDenyLists cog. --- bot/cogs/allow_deny_lists.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/bot/cogs/allow_deny_lists.py b/bot/cogs/allow_deny_lists.py index d03c774ec..6558990a7 100644 --- a/bot/cogs/allow_deny_lists.py +++ b/bot/cogs/allow_deny_lists.py @@ -29,6 +29,7 @@ class AllowDenyLists(Cog): allow_type = "whitelist" if allowed else "blacklist" # Try to add the item to the database + log.trace(f"Trying to add the {content} item to the {list_type} {allow_type}") try: item = await self.bot.api_client.post( "bot/allow_deny_lists", @@ -37,6 +38,10 @@ class AllowDenyLists(Cog): except ResponseCodeError as e: if e.status == 500: await ctx.message.add_reaction("❌") + log.debug( + f"{ctx.author} tried to add data to a {allow_type}, but the API returned 500, " + "probably because the request violated the UniqueConstraint." + ) raise BadArgument( f"Unable to add the item to the {allow_type}. " "The item probably already exists. Keep in mind that a " @@ -60,6 +65,9 @@ class AllowDenyLists(Cog): async def _delete_data(self, ctx: Context, allowed: bool, list_type: ValidAllowDenyListType, content: str) -> None: """Remove an item from an allow or denylist.""" item = None + allow_type = "whitelist" if allowed else "blacklist" + + log.trace(f"Trying to delete the {content} item from the {list_type} {allow_type}") for allow_list in self.bot.allow_deny_list_cache.get(f"{list_type}.{allowed}", []): if content == allow_list.get("content"): @@ -77,11 +85,12 @@ class AllowDenyLists(Cog): """Paginate and display all items in an allow or denylist.""" result = self.bot.allow_deny_list_cache.get(f"{list_type}.{allowed}", []) lines = sorted(f"• {item.get('content')}" for item in result) - allowed_string = "Whitelisted" if allowed else "Blacklisted" + allow_type = "whitelist" if allowed else "blacklist" embed = Embed( - title=f"{allowed_string} {list_type.lower()} items ({len(result)} total)", + title=f"{allow_type.title()}ed {list_type.lower()} items ({len(result)} total)", colour=Colour.blue() ) + log.trace(f"Trying to list {len(result)} items from the {list_type.lower()} {allow_type}") if result: await LinePaginator.paginate(lines, ctx, embed, max_lines=15, empty=False) -- cgit v1.2.3 From d07b1af634787f53ee381d31a4c125498af52beb Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 18 Jul 2020 15:55:56 +0200 Subject: Remove Filtering constants, use cache data. Instead of fetching the guild invite IDs from config-default.yml, we will now be using the AllowDenyList cache to check these. --- bot/cogs/filtering.py | 62 ++++++++++++++++--------------- bot/constants.py | 4 -- config-default.yml | 101 -------------------------------------------------- 3 files changed, 32 insertions(+), 135 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index bd665f424..9e35a83d1 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -22,6 +22,7 @@ from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) +# Regular expressions INVITE_RE = re.compile( r"(?:discord(?:[\.,]|dot)gg|" # Could be discord.gg/ r"discord(?:[\.,]|dot)com(?:\/|slash)invite|" # or discord.com/invite/ @@ -37,25 +38,8 @@ SPOILER_RE = re.compile(r"(\|\|.+?\|\|)", re.DOTALL) URL_RE = re.compile(r"(https?://[^\s]+)", flags=re.IGNORECASE) ZALGO_RE = re.compile(r"[\u0300-\u036F\u0489]") -WORD_WATCHLIST_PATTERNS = [ - re.compile(fr'\b{expression}\b', flags=re.IGNORECASE) for expression in Filter.word_watchlist -] -TOKEN_WATCHLIST_PATTERNS = [ - re.compile(fr'{expression}', flags=re.IGNORECASE) for expression in Filter.token_watchlist -] -WATCHLIST_PATTERNS = WORD_WATCHLIST_PATTERNS + TOKEN_WATCHLIST_PATTERNS - +# Other constants. DAYS_BETWEEN_ALERTS = 3 - - -def expand_spoilers(text: str) -> str: - """Return a string containing all interpretations of a spoilered message.""" - split_text = SPOILER_RE.split(text) - return ''.join( - split_text[0::2] + split_text[1::2] + split_text - ) - - OFFENSIVE_MSG_DELETE_TIME = timedelta(days=Filter.offensive_msg_delete_days) @@ -125,6 +109,23 @@ class Filtering(Cog): self.bot.loop.create_task(self.reschedule_offensive_msg_deletion()) + def _get_allowlist_items(self, allow: bool, list_type: str, compiled: Optional[bool] = False) -> list: + """Fetch items from the allow_deny_list_cache.""" + items = self.bot.allow_deny_list_cache[f"{list_type}.{allow}"] + + if compiled: + return [re.compile(fr'{item.get("content")}', flags=re.IGNORECASE) for item in items] + else: + return [item.get("content") for item in items] + + @staticmethod + def _expand_spoilers(text: str) -> str: + """Return a string containing all interpretations of a spoilered message.""" + split_text = SPOILER_RE.split(text) + return ''.join( + split_text[0::2] + split_text[1::2] + split_text + ) + @property def mod_log(self) -> ModLog: """Get currently loaded ModLog cog instance.""" @@ -149,11 +150,11 @@ class Filtering(Cog): delta = relativedelta(after.edited_at, before.edited_at).microseconds await self._filter_message(after, delta) - @staticmethod - def get_name_matches(name: str) -> List[re.Match]: + def get_name_matches(self, name: str) -> List[re.Match]: """Check bad words from passed string (name). Return list of matches.""" matches = [] - for pattern in WATCHLIST_PATTERNS: + watchlist_patterns = self._get_allowlist_items(False, 'word_watchlist', compiled=True) + for pattern in watchlist_patterns: if match := pattern.search(name): matches.append(match) return matches @@ -403,8 +404,7 @@ class Filtering(Cog): and not msg.author.bot # Author not a bot ) - @staticmethod - async def _has_watch_regex_match(text: str) -> Union[bool, re.Match]: + async def _has_watch_regex_match(self, text: str) -> Union[bool, re.Match]: """ Return True if `text` matches any regex from `word_watchlist` or `token_watchlist` configs. @@ -412,26 +412,27 @@ class Filtering(Cog): matched as-is. Spoilers are expanded, if any, and URLs are ignored. """ if SPOILER_RE.search(text): - text = expand_spoilers(text) + text = self._expand_spoilers(text) # Make sure it's not a URL if URL_RE.search(text): return False - for pattern in WATCHLIST_PATTERNS: + watchlist_patterns = self._get_allowlist_items(False, 'word_watchlist', compiled=True) + for pattern in watchlist_patterns: match = pattern.search(text) if match: return match - @staticmethod - async def _has_urls(text: str) -> bool: + async def _has_urls(self, text: str) -> bool: """Returns True if the text contains one of the blacklisted URLs from the config file.""" if not URL_RE.search(text): return False text = text.lower() + domain_blacklist = self._get_allowlist_items(False, "domain_name") - for url in Filter.domain_blacklist: + for url in domain_blacklist: if url.lower() in text: return True @@ -476,9 +477,10 @@ class Filtering(Cog): # between invalid and expired invites return True - guild_id = int(guild.get("id")) + guild_id = guild.get("id") + guild_invite_whitelist = self._get_allowlist_items(True, "guild_invite_id") - if guild_id not in Filter.guild_invite_whitelist: + if guild_id not in guild_invite_whitelist: guild_icon_hash = guild["icon"] guild_icon = ( "https://cdn.discordapp.com/icons/" diff --git a/bot/constants.py b/bot/constants.py index 778bc093c..f5245ca50 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -227,10 +227,6 @@ class Filter(metaclass=YAMLGetter): ping_everyone: bool offensive_msg_delete_days: int - guild_invite_whitelist: List[int] - domain_blacklist: List[str] - word_watchlist: List[str] - token_watchlist: List[str] channel_whitelist: List[int] role_whitelist: List[int] diff --git a/config-default.yml b/config-default.yml index f2eb17b89..81c8c40d5 100644 --- a/config-default.yml +++ b/config-default.yml @@ -272,107 +272,6 @@ filter: ping_everyone: true offensive_msg_delete_days: 7 # How many days before deleting an offensive message? - guild_invite_whitelist: - - 280033776820813825 # Functional Programming - - 267624335836053506 # Python Discord - - 440186186024222721 # Python Discord: Emojis 1 - - 578587418123304970 # Python Discord: Emojis 2 - - 273944235143593984 # STEM - - 348658686962696195 # RLBot - - 531221516914917387 # Pallets - - 249111029668249601 # Gentoo - - 327254708534116352 # Adafruit - - 544525886180032552 # kennethreitz.org - - 590806733924859943 # Discord Hack Week - - 423249981340778496 # Kivy - - 197038439483310086 # Discord Testers - - 286633898581164032 # Ren'Py - - 349505959032389632 # PyGame - - 438622377094414346 # Pyglet - - 524691714909274162 # Panda3D - - 336642139381301249 # discord.py - - 405403391410438165 # Sentdex - - 172018499005317120 # The Coding Den - - 666560367173828639 # PyWeek - - 702724176489873509 # Microsoft Python - - 150662382874525696 # Microsoft Community - - 81384788765712384 # Discord API - - 613425648685547541 # Discord Developers - - 185590609631903755 # Blender Hub - - 420324994703163402 # /r/FlutterDev - - 488751051629920277 # Python Atlanta - - 143867839282020352 # C# - - 159039020565790721 # Django - - 238666723824238602 # Programming Discussions - - 433980600391696384 # JetBrains Community - - 204621105720328193 # Raspberry Pi - - 244230771232079873 # Programmers Hangout - - 239433591950540801 # SpeakJS - - 174075418410876928 # DevCord - - 489222168727519232 # Unity - - 494558898880118785 # Programmer Humor - - domain_blacklist: - - pornhub.com - - liveleak.com - - grabify.link - - bmwforum.co - - leancoding.co - - spottyfly.com - - stopify.co - - yoütu.be - - discörd.com - - minecräft.com - - freegiftcards.co - - disçordapp.com - - fortnight.space - - fortnitechat.site - - joinmy.site - - curiouscat.club - - catsnthings.fun - - yourtube.site - - youtubeshort.watch - - catsnthing.com - - youtubeshort.pro - - canadianlumberjacks.online - - poweredbydialup.club - - poweredbydialup.online - - poweredbysecurity.org - - poweredbysecurity.online - - ssteam.site - - steamwalletgift.com - - discord.gift - - lmgtfy.com - - word_watchlist: - - goo+ks* - - ky+s+ - - ki+ke+s* - - beaner+s? - - coo+ns* - - nig+lets* - - slant-eyes* - - towe?l-?head+s* - - chi*n+k+s* - - spick*s* - - kill* +(?:yo)?urself+ - - jew+s* - - suicide - - rape - - (re+)tar+(d+|t+)(ed)? - - ta+r+d+ - - cunts* - - trann*y - - shemale - - token_watchlist: - - fa+g+s* - - 卐 - - 卍 - - cuck(?!oo+) - - nigg+(?:e*r+|a+h*?|u+h+)s? - - fag+o+t+s* - # Censor doesn't apply to these channel_whitelist: - *ADMINS -- cgit v1.2.3 From 1c569f2f38fe18d6210deec001046cf9ee68ea53 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 18 Jul 2020 16:54:01 +0200 Subject: Remove AntiMalWare constants, use cache data. Also updates the tests for this cog. --- bot/bot.py | 2 +- bot/cogs/antimalware.py | 24 ++++++++++++++---------- bot/constants.py | 6 ------ config-default.yml | 29 ----------------------------- tests/bot/cogs/test_antimalware.py | 24 +++++++++++++++--------- 5 files changed, 30 insertions(+), 55 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 6c02e72a7..962c8dd93 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -34,6 +34,7 @@ class Bot(commands.Bot): self.redis_ready = asyncio.Event() self.redis_closed = False self.api_client = api.APIClient(loop=self.loop) + self.allow_deny_list_cache = {} self._connector = None self._resolver = None @@ -52,7 +53,6 @@ class Bot(commands.Bot): async def _cache_allow_deny_list_data(self) -> None: """Cache all the data in the AllowDenyList on the site.""" full_cache = await self.api_client.get('bot/allow_deny_lists') - self.allow_deny_list_cache = {} for item in full_cache: type_ = item.get("type") diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index ea257442e..38ff1133d 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -6,7 +6,7 @@ from discord import Embed, Message, NotFound from discord.ext.commands import Cog from bot.bot import Bot -from bot.constants import AntiMalware as AntiMalwareConfig, Channels, STAFF_ROLES, URLs +from bot.constants import Channels, STAFF_ROLES, URLs log = logging.getLogger(__name__) @@ -27,7 +27,7 @@ TXT_EMBED_DESCRIPTION = ( DISALLOWED_EMBED_DESCRIPTION = ( "It looks like you tried to attach file type(s) that we do not allow ({blocked_extensions_str}). " - f"We currently allow the following file types: **{', '.join(AntiMalwareConfig.whitelist)}**.\n\n" + "We currently allow the following file types: **{joined_whitelist}**.\n\n" "Feel free to ask in {meta_channel_mention} if you think this is a mistake." ) @@ -38,6 +38,16 @@ class AntiMalware(Cog): def __init__(self, bot: Bot): self.bot = bot + def _get_whitelisted_file_formats(self) -> list: + """Get the file formats currently on the whitelist.""" + return [item.get('content') for item in self.bot.allow_deny_list_cache['file_format.True']] + + def _get_disallowed_extensions(self, message: Message) -> t.Iterable[str]: + """Get an iterable containing all the disallowed extensions of attachments.""" + file_extensions = {splitext(attachment.filename.lower())[1] for attachment in message.attachments} + extensions_blocked = file_extensions - set(self._get_whitelisted_file_formats()) + return extensions_blocked + @Cog.listener() async def on_message(self, message: Message) -> None: """Identify messages with prohibited attachments.""" @@ -51,7 +61,7 @@ class AntiMalware(Cog): return embed = Embed() - extensions_blocked = self.get_disallowed_extensions(message) + extensions_blocked = self._get_disallowed_extensions(message) blocked_extensions_str = ', '.join(extensions_blocked) if ".py" in extensions_blocked: # Short-circuit on *.py files to provide a pastebin link @@ -63,6 +73,7 @@ class AntiMalware(Cog): elif extensions_blocked: meta_channel = self.bot.get_channel(Channels.meta) embed.description = DISALLOWED_EMBED_DESCRIPTION.format( + joined_whitelist=', '.join(self._get_whitelisted_file_formats()), blocked_extensions_str=blocked_extensions_str, meta_channel_mention=meta_channel.mention, ) @@ -81,13 +92,6 @@ class AntiMalware(Cog): except NotFound: log.info(f"Tried to delete message `{message.id}`, but message could not be found.") - @classmethod - def get_disallowed_extensions(cls, message: Message) -> t.Iterable[str]: - """Get an iterable containing all the disallowed extensions of attachments.""" - file_extensions = {splitext(attachment.filename.lower())[1] for attachment in message.attachments} - extensions_blocked = file_extensions - set(AntiMalwareConfig.whitelist) - return extensions_blocked - def setup(bot: Bot) -> None: """Load the AntiMalware cog.""" diff --git a/bot/constants.py b/bot/constants.py index f5245ca50..857e6c4f0 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -527,12 +527,6 @@ class AntiSpam(metaclass=YAMLGetter): rules: Dict[str, Dict[str, int]] -class AntiMalware(metaclass=YAMLGetter): - section = "anti_malware" - - whitelist: list - - class BigBrother(metaclass=YAMLGetter): section = 'big_brother' diff --git a/config-default.yml b/config-default.yml index 81c8c40d5..503cc2b52 100644 --- a/config-default.yml +++ b/config-default.yml @@ -386,35 +386,6 @@ anti_spam: max: 3 -anti_malware: - whitelist: - - '.3gp' - - '.3g2' - - '.avi' - - '.bmp' - - '.gif' - - '.h264' - - '.jpg' - - '.jpeg' - - '.m4v' - - '.mkv' - - '.mov' - - '.mp4' - - '.mpeg' - - '.mpg' - - '.png' - - '.tiff' - - '.wmv' - - '.svg' - - '.psd' # Photoshop - - '.ai' # Illustrator - - '.aep' # After Effects - - '.xcf' # GIMP - - '.mp3' - - '.wav' - - '.ogg' - - reddit: subreddits: - 'r/Python' diff --git a/tests/bot/cogs/test_antimalware.py b/tests/bot/cogs/test_antimalware.py index f219fc1ba..1e010d2ce 100644 --- a/tests/bot/cogs/test_antimalware.py +++ b/tests/bot/cogs/test_antimalware.py @@ -1,28 +1,33 @@ import unittest -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, Mock from discord import NotFound from bot.cogs import antimalware -from bot.constants import AntiMalware as AntiMalwareConfig, Channels, STAFF_ROLES +from bot.constants import Channels, STAFF_ROLES from tests.helpers import MockAttachment, MockBot, MockMessage, MockRole -MODULE = "bot.cogs.antimalware" - -@patch(f"{MODULE}.AntiMalwareConfig.whitelist", new=[".first", ".second", ".third"]) class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): """Test the AntiMalware cog.""" def setUp(self): """Sets up fresh objects for each test.""" self.bot = MockBot() + self.bot.allow_deny_list_cache = { + "file_format.True": [ + {"content": ".first"}, + {"content": ".second"}, + {"content": ".third"} + ] + } self.cog = antimalware.AntiMalware(self.bot) self.message = MockMessage() + self.whitelist = [".first", ".second", ".third"] async def test_message_with_allowed_attachment(self): """Messages with allowed extensions should not be deleted""" - attachment = MockAttachment(filename=f"python{AntiMalwareConfig.whitelist[0]}") + attachment = MockAttachment(filename="python.first") self.message.attachments = [attachment] await self.cog.on_message(self.message) @@ -93,7 +98,7 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(embed.description, antimalware.TXT_EMBED_DESCRIPTION.format.return_value) antimalware.TXT_EMBED_DESCRIPTION.format.assert_called_with(cmd_channel_mention=cmd_channel.mention) - async def test_other_disallowed_extention_embed_description(self): + async def test_other_disallowed_extension_embed_description(self): """Test the description for a non .py/.txt disallowed extension.""" attachment = MockAttachment(filename="python.disallowed") self.message.attachments = [attachment] @@ -109,6 +114,7 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(embed.description, antimalware.DISALLOWED_EMBED_DESCRIPTION.format.return_value) antimalware.DISALLOWED_EMBED_DESCRIPTION.format.assert_called_with( + joined_whitelist=", ".join(self.whitelist), blocked_extensions_str=".disallowed", meta_channel_mention=meta_channel.mention ) @@ -135,7 +141,7 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): """The return value should include all non-whitelisted extensions.""" test_values = ( ([], []), - (AntiMalwareConfig.whitelist, []), + (self.whitelist, []), ([".first"], []), ([".first", ".disallowed"], [".disallowed"]), ([".disallowed"], [".disallowed"]), @@ -145,7 +151,7 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): for extensions, expected_disallowed_extensions in test_values: with self.subTest(extensions=extensions, expected_disallowed_extensions=expected_disallowed_extensions): self.message.attachments = [MockAttachment(filename=f"filename{extension}") for extension in extensions] - disallowed_extensions = self.cog.get_disallowed_extensions(self.message) + disallowed_extensions = self.cog._get_disallowed_extensions(self.message) self.assertCountEqual(disallowed_extensions, expected_disallowed_extensions) -- cgit v1.2.3 From cdf4e2595b8321158bcb514936b7c2a23a88cd0d Mon Sep 17 00:00:00 2001 From: Kieran Siek Date: Sun, 19 Jul 2020 13:10:14 +0800 Subject: Add whitespace to improve readability Co-authored-by: Mark --- bot/cogs/reminders.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 9eddd283b..5f76164cd 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -121,6 +121,7 @@ class Reminders(Cog): Conditions: - Role reminders are Mods+ - Reminders for other users are Helpers+ + If mentions aren't allowed, also return the type of mention(s) disallowed. """ if without_role_check(ctx, *STAFF_ROLES): -- cgit v1.2.3 From 1d9efd32278688adebd539b15c4d16d4dd88e74c Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sun, 19 Jul 2020 13:17:45 +0800 Subject: Namespace Member and Role to avoid extra imports --- bot/cogs/reminders.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 9eddd283b..ae387f09a 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -9,7 +9,6 @@ from operator import itemgetter import discord from dateutil.parser import isoparse from dateutil.relativedelta import relativedelta -from discord import Member, Role from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot @@ -25,7 +24,7 @@ log = logging.getLogger(__name__) WHITELISTED_CHANNELS = Guild.reminder_whitelist MAXIMUM_REMINDERS = 5 -Mentionable = t.Union[Member, Role] +Mentionable = t.Union[discord.Member, discord.Role] class Reminders(Cog): @@ -126,7 +125,7 @@ class Reminders(Cog): if without_role_check(ctx, *STAFF_ROLES): return False, "members/roles" elif without_role_check(ctx, *MODERATION_ROLES): - return all(isinstance(mention, Member) for mention in mentions), "roles" + return all(isinstance(mention, discord.Member) for mention in mentions), "roles" else: return True, "" -- cgit v1.2.3 From ced9117e848a9eb1e003576d4f355ba7aa220cd8 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sun, 19 Jul 2020 13:31:00 +0800 Subject: Extract `send_denial` to a utility function --- bot/cogs/reminders.py | 21 ++++++--------------- bot/utils/messages.py | 16 ++++++++++++++-- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index ae387f09a..f36b67f5a 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -12,10 +12,11 @@ from dateutil.relativedelta import relativedelta from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot -from bot.constants import Guild, Icons, MODERATION_ROLES, NEGATIVE_REPLIES, POSITIVE_REPLIES, STAFF_ROLES +from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, STAFF_ROLES from bot.converters import Duration from bot.pagination import LinePaginator from bot.utils.checks import without_role_check +from bot.utils.messages import send_denial from bot.utils.scheduling import Scheduler from bot.utils.time import humanize_delta @@ -102,16 +103,6 @@ class Reminders(Cog): await ctx.send(embed=embed) - @staticmethod - async def _send_denial(ctx: Context, reason: str) -> None: - """Send an embed denying the user from creating a reminder.""" - embed = discord.Embed() - embed.colour = discord.Colour.red() - embed.title = random.choice(NEGATIVE_REPLIES) - embed.description = reason - - await ctx.send(embed=embed) - @staticmethod async def allow_mentions(ctx: Context, mentions: t.List[Mentionable]) -> t.Tuple[bool, str]: """ @@ -220,7 +211,7 @@ class Reminders(Cog): # If they don't have permission to set a reminder in this channel if ctx.channel.id not in WHITELISTED_CHANNELS: - return await self._send_denial(ctx, "Sorry, you can't do that here!") + return await send_denial(ctx, "Sorry, you can't do that here!") # Get their current active reminders active_reminders = await self.bot.api_client.get( @@ -233,13 +224,13 @@ class Reminders(Cog): # Let's limit this, so we don't get 10 000 # reminders from kip or something like that :P if len(active_reminders) > MAXIMUM_REMINDERS: - return await self._send_denial(ctx, "You have too many active reminders!") + return await send_denial(ctx, "You have too many active reminders!") # Filter mentions to see if the user can mention members/roles if mentions: mentions_allowed, disallowed_mentions = await self.allow_mentions(ctx, mentions) if not mentions_allowed: - return await self._send_denial( + return await send_denial( ctx, f"You can't mention other {disallowed_mentions} in your reminder!" ) @@ -389,7 +380,7 @@ class Reminders(Cog): # Filter mentions to see if the user can mention members/roles mentions_allowed, disallowed_mentions = await self.allow_mentions(ctx, mentions) if not mentions_allowed: - return await self._send_denial( + return await send_denial( ctx, f"You can't mention other {disallowed_mentions} in your reminder!" ) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index a40a12e98..670289941 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -1,15 +1,17 @@ import asyncio import contextlib import logging +import random import re from io import BytesIO from typing import List, Optional, Sequence, Union -from discord import Client, Embed, File, Member, Message, Reaction, TextChannel, Webhook +from discord import Client, Colour, Embed, File, Member, Message, Reaction, TextChannel, Webhook from discord.abc import Snowflake from discord.errors import HTTPException +from discord.ext.commands import Context -from bot.constants import Emojis +from bot.constants import Emojis, NEGATIVE_REPLIES log = logging.getLogger(__name__) @@ -132,3 +134,13 @@ def sub_clyde(username: Optional[str]) -> Optional[str]: return re.sub(r"(clyd)(e)", replace_e, username, flags=re.I) else: return username # Empty string or None + + +async def send_denial(ctx: Context, reason: str) -> None: + """Send an embed denying the user with the given reason.""" + embed = Embed() + embed.colour = Colour.red() + embed.title = random.choice(NEGATIVE_REPLIES) + embed.description = reason + + await ctx.send(embed=embed) -- cgit v1.2.3 From 010373673700f821b5860bd749f40fdf8d59d134 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sun, 19 Jul 2020 13:39:24 +0800 Subject: Fix incorrect typehint and shorten method name --- bot/cogs/reminders.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index f36b67f5a..d36494a69 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -120,7 +120,7 @@ class Reminders(Cog): else: return True, "" - def get_mentionables_from_ids(self, mention_ids: t.List[str]) -> t.Iterator[Mentionable]: + def get_mentionables(self, mention_ids: t.List[int]) -> t.Iterator[Mentionable]: """Converts Role and Member ids to their corresponding objects if possible.""" guild = self.bot.get_guild(Guild.id) for mention_id in mention_ids: @@ -182,7 +182,7 @@ class Reminders(Cog): ) additional_mentions = ' '.join( - mentionable.mention for mentionable in self.get_mentionables_from_ids(reminder["mentions"]) + mentionable.mention for mentionable in self.get_mentionables(reminder["mentions"]) ) await channel.send( @@ -295,7 +295,7 @@ class Reminders(Cog): mentions = ", ".join( # Both Role and User objects have the `name` attribute - mention.name for mention in self.get_mentionables_from_ids(mentions) + mention.name for mention in self.get_mentionables(mentions) ) mention_string = f"\n**Mentions:** {mentions}" if mentions else "" -- cgit v1.2.3 From 0d51d357a5a9f192c8ed71d40726838b7fb5136e Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 19 Jul 2020 10:41:49 +0200 Subject: Fix an absolutely terrible comment. --- bot/cogs/filtering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 9e35a83d1..d94c19471 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -456,7 +456,7 @@ class Filtering(Cog): Attempts to catch some of common ways to try to cheat the system. """ - # Remove backslashes to prevent escape character around fuckery like + # Remove backslashes to prevent escape character aroundfuckery like # discord\.gg/gdudes-pony-farm text = text.replace("\\", "") -- cgit v1.2.3 From da260365d3a6d9b92a630b16e32397b52d64e6c3 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 19 Jul 2020 12:19:34 +0200 Subject: Include the guild ID in mod-log embed. This gives easier access to the Guild ID in the place where you're most likely to want to use the whitelist command. --- bot/cogs/filtering.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index d94c19471..4d51bba2e 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -111,7 +111,7 @@ class Filtering(Cog): def _get_allowlist_items(self, allow: bool, list_type: str, compiled: Optional[bool] = False) -> list: """Fetch items from the allow_deny_list_cache.""" - items = self.bot.allow_deny_list_cache[f"{list_type}.{allow}"] + items = self.bot.allow_deny_list_cache.get(f"{list_type.upper()}.{allow}", []) if compiled: return [re.compile(fr'{item.get("content")}', flags=re.IGNORECASE) for item in items] @@ -371,14 +371,14 @@ class Filtering(Cog): # They have no data so additional embeds can't be created for them. if name == "filter_invites" and match is not True: additional_embeds = [] - for invite, data in match.items(): + for _, data in match.items(): embed = discord.Embed(description=( f"**Members:**\n{data['members']}\n" f"**Active:**\n{data['active']}" )) embed.set_author(name=data["name"]) embed.set_thumbnail(url=data["icon"]) - embed.set_footer(text=f"Guild Invite Code: {invite}") + embed.set_footer(text=f"Guild ID: {data['id']}") additional_embeds.append(embed) additional_embeds_msg = "For the following guild(s):" @@ -489,6 +489,7 @@ class Filtering(Cog): invite_data[invite] = { "name": guild["name"], + "id": guild['id'], "icon": guild_icon, "members": response["approximate_member_count"], "active": response["approximate_presence_count"] -- cgit v1.2.3 From 064130f7838647ab7bb63824446d93ba50833126 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 19 Jul 2020 12:31:04 +0200 Subject: Support the new AllowDenyList field, 'comment'. --- bot/bot.py | 1 + bot/cogs/allow_deny_lists.py | 55 ++++++++++++++++++++++++++++++++++++-------- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 962c8dd93..d834c151b 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -59,6 +59,7 @@ class Bot(commands.Bot): allowed = item.get("allowed") metadata = { "content": item.get("content"), + "comment": item.get("comment"), "id": item.get("id"), "created_at": item.get("created_at"), "updated_at": item.get("updated_at"), diff --git a/bot/cogs/allow_deny_lists.py b/bot/cogs/allow_deny_lists.py index 6558990a7..8b3c892f5 100644 --- a/bot/cogs/allow_deny_lists.py +++ b/bot/cogs/allow_deny_lists.py @@ -1,4 +1,5 @@ import logging +from typing import Optional from discord import Colour, Embed from discord.ext.commands import BadArgument, Cog, Context, group @@ -19,17 +20,26 @@ class AllowDenyLists(Cog): def __init__(self, bot: Bot) -> None: self.bot = bot - async def _add_data(self, ctx: Context, allowed: bool, list_type: ValidAllowDenyListType, content: str) -> None: + async def _add_data( + self, + ctx: Context, + allowed: bool, + list_type: ValidAllowDenyListType, + content: str, + comment: Optional[str] = None, + ) -> None: """Add an item to an allow or denylist.""" + allow_type = "whitelist" if allowed else "blacklist" + + # Try to add the item to the database + log.trace(f"Trying to add the {content} item to the {list_type} {allow_type}") payload = { 'allowed': allowed, 'type': list_type, 'content': content, + 'comment': comment, } - allow_type = "whitelist" if allowed else "blacklist" - # Try to add the item to the database - log.trace(f"Trying to add the {content} item to the {list_type} {allow_type}") try: item = await self.bot.api_client.post( "bot/allow_deny_lists", @@ -55,6 +65,7 @@ class AllowDenyLists(Cog): allowed = item.get("allowed") metadata = { "content": item.get("content"), + "comment": item.get("comment"), "id": item.get("id"), "created_at": item.get("created_at"), "updated_at": item.get("updated_at"), @@ -83,9 +94,21 @@ class AllowDenyLists(Cog): async def _list_all_data(self, ctx: Context, allowed: bool, list_type: ValidAllowDenyListType) -> None: """Paginate and display all items in an allow or denylist.""" - result = self.bot.allow_deny_list_cache.get(f"{list_type}.{allowed}", []) - lines = sorted(f"• {item.get('content')}" for item in result) allow_type = "whitelist" if allowed else "blacklist" + result = self.bot.allow_deny_list_cache.get(f"{list_type}.{allowed}", []) + + # Build a list of lines we want to show in the paginator + lines = [] + for item in result: + line = f"• {item.get('content')}" + + if item.get("comment"): + line += f" ({item.get('comment')})" + + lines.append(line) + lines = sorted(lines) + + # Build the embed embed = Embed( title=f"{allow_type.title()}ed {list_type.lower()} items ({len(result)} total)", colour=Colour.blue() @@ -111,14 +134,26 @@ class AllowDenyLists(Cog): await ctx.send_help(ctx.command) @whitelist.command(name="add", aliases=("a", "set")) - async def allow_add(self, ctx: Context, list_type: ValidAllowDenyListType, content: str) -> None: + async def allow_add( + self, + ctx: Context, + list_type: ValidAllowDenyListType, + content: str, + comment: Optional[str] = None, + ) -> None: """Add an item to the specified allowlist.""" - await self._add_data(ctx, True, list_type, content) + await self._add_data(ctx, True, list_type, content, comment) @blacklist.command(name="add", aliases=("a", "set")) - async def deny_add(self, ctx: Context, list_type: ValidAllowDenyListType, content: str) -> None: + async def deny_add( + self, + ctx: Context, + list_type: ValidAllowDenyListType, + content: str, + comment: Optional[str] = None, + ) -> None: """Add an item to the specified denylist.""" - await self._add_data(ctx, False, list_type, content) + await self._add_data(ctx, False, list_type, content, comment) @whitelist.command(name="remove", aliases=("delete", "rm",)) async def allow_delete(self, ctx: Context, list_type: ValidAllowDenyListType, content: str) -> None: -- cgit v1.2.3 From 4ef9770ed26b46bc5916f4147acce1cf57f4f9af Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sun, 19 Jul 2020 21:13:08 +0800 Subject: Rename method to improve readability --- bot/cogs/reminders.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index d36494a69..6755993a0 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -104,7 +104,7 @@ class Reminders(Cog): await ctx.send(embed=embed) @staticmethod - async def allow_mentions(ctx: Context, mentions: t.List[Mentionable]) -> t.Tuple[bool, str]: + async def check_mentions(ctx: Context, mentions: t.List[Mentionable]) -> t.Tuple[bool, str]: """ Returns whether or not the list of mentions is allowed. @@ -228,7 +228,7 @@ class Reminders(Cog): # Filter mentions to see if the user can mention members/roles if mentions: - mentions_allowed, disallowed_mentions = await self.allow_mentions(ctx, mentions) + mentions_allowed, disallowed_mentions = await self.check_mentions(ctx, mentions) if not mentions_allowed: return await send_denial( ctx, f"You can't mention other {disallowed_mentions} in your reminder!" @@ -378,7 +378,7 @@ class Reminders(Cog): async def edit_reminder_mentions(self, ctx: Context, id_: int, mentions: Greedy[Mentionable]) -> None: """Edit one of your reminder's mentions.""" # Filter mentions to see if the user can mention members/roles - mentions_allowed, disallowed_mentions = await self.allow_mentions(ctx, mentions) + mentions_allowed, disallowed_mentions = await self.check_mentions(ctx, mentions) if not mentions_allowed: return await send_denial( ctx, f"You can't mention other {disallowed_mentions} in your reminder!" -- cgit v1.2.3 From ff7707f8306c26ef9d14944e2826671bbcfcf113 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sun, 19 Jul 2020 22:17:48 +0800 Subject: Refactor reminder edits to reduce code duplication The reminder expiration returnedfrom the API call is also now parsed again even when the edit is to the duration since it does not matter and trying to keep it DRY while still doing that check is a pain. --- bot/cogs/reminders.py | 65 ++++++++++++++++++++------------------------------- 1 file changed, 25 insertions(+), 40 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 6755993a0..d99979ace 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -148,6 +148,19 @@ class Reminders(Cog): # Now we can remove it from the schedule list self.scheduler.cancel(reminder_id) + async def _edit_reminder(self, reminder_id: int, payload: dict) -> dict: + """ + Edits a reminder in the database given the ID and payload. + + Returns the edited reminder. + """ + # Send the request to update the reminder in the database + reminder = await self.bot.api_client.patch( + 'bot/reminders/' + str(reminder_id), + json=payload + ) + return reminder + async def _reschedule_reminder(self, reminder: dict) -> None: """Reschedule a reminder object.""" log.trace(f"Cancelling old task #{reminder['id']}") @@ -300,7 +313,7 @@ class Reminders(Cog): mention_string = f"\n**Mentions:** {mentions}" if mentions else "" text = textwrap.dedent(f""" - **Reminder #{id_}:** *expires in {time}* (ID: {id_}) {mention_string} + **Reminder #{id_}:** *expires in {time}* (ID: {id_}){mention_string} {content} """).strip() @@ -333,46 +346,16 @@ class Reminders(Cog): @edit_reminder_group.command(name="duration", aliases=("time",)) async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: Duration) -> None: """ - Edit one of your reminder's expiration. + Edit one of your reminder's expiration. Expiration is parsed per: http://strftime.org/ """ - # Send the request to update the reminder in the database - reminder = await self.bot.api_client.patch( - 'bot/reminders/' + str(id_), - json={'expiration': expiration.isoformat()} - ) - - # Send a confirmation message to the channel - await self._send_confirmation( - ctx, - on_success="That reminder has been edited successfully!", - reminder_id=id_, - delivery_dt=expiration, - ) - - await self._reschedule_reminder(reminder) + await self.edit_reminder(ctx, id_, {'expiration': expiration.isoformat()}) @edit_reminder_group.command(name="content", aliases=("reason",)) async def edit_reminder_content(self, ctx: Context, id_: int, *, content: str) -> None: """Edit one of your reminder's content.""" - # Send the request to update the reminder in the database - reminder = await self.bot.api_client.patch( - 'bot/reminders/' + str(id_), - json={'content': content} - ) - - # Parse the reminder expiration back into a datetime for the confirmation message - expiration = isoparse(reminder['expiration']).replace(tzinfo=None) - - # Send a confirmation message to the channel - await self._send_confirmation( - ctx, - on_success="That reminder has been edited successfully!", - reminder_id=id_, - delivery_dt=expiration, - ) - await self._reschedule_reminder(reminder) + await self.edit_reminder(ctx, id_, {"content": content}) @edit_reminder_group.command(name="mentions", aliases=("pings",)) async def edit_reminder_mentions(self, ctx: Context, id_: int, mentions: Greedy[Mentionable]) -> None: @@ -385,13 +368,15 @@ class Reminders(Cog): ) mention_ids = [mention.id for mention in mentions] - reminder = await self.bot.api_client.patch( - 'bot/reminders/' + str(id_), - json={"mentions": mention_ids} - ) - # Parse the reminder expiration back into a datetime for the confirmation message - expiration = isoparse(reminder['expiration']).replace(tzinfo=None) + await self.edit_reminder(ctx, id_, {"mentions": mention_ids}) + + async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: + """Edits a reminder with the given payload, then sends a confirmation message.""" + reminder = await self._edit_reminder(id_, payload) + + # Parse the reminder expiration back into a datetime + expiration = isoparse(reminder["expiration"]).replace(tzinfo=None) # Send a confirmation message to the channel await self._send_confirmation( -- cgit v1.2.3 From c491d054daaa9f3e2ff3ecffba626b9991f93005 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sun, 19 Jul 2020 22:37:08 +0800 Subject: Move mentions validation to another method --- bot/cogs/reminders.py | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index d99979ace..cc20897e0 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -104,7 +104,7 @@ class Reminders(Cog): await ctx.send(embed=embed) @staticmethod - async def check_mentions(ctx: Context, mentions: t.List[Mentionable]) -> t.Tuple[bool, str]: + async def _check_mentions(ctx: Context, mentions: t.List[Mentionable]) -> t.Tuple[bool, str]: """ Returns whether or not the list of mentions is allowed. @@ -120,6 +120,21 @@ class Reminders(Cog): else: return True, "" + @staticmethod + async def validate_mentions(ctx: Context, mentions: t.List[Mentionable]) -> bool: + """ + Filter mentions to see if the user can mention, and sends a denial if not allowed. + + Returns whether or not the validation is successful. + """ + mentions_allowed, disallowed_mentions = await Reminders._check_mentions(ctx, mentions) + + if not mentions or mentions_allowed: + return True + else: + await send_denial(ctx, f"You can't mention other {disallowed_mentions} in your reminder!") + return False + def get_mentionables(self, mention_ids: t.List[int]) -> t.Iterator[Mentionable]: """Converts Role and Member ids to their corresponding objects if possible.""" guild = self.bot.get_guild(Guild.id) @@ -240,12 +255,8 @@ class Reminders(Cog): return await send_denial(ctx, "You have too many active reminders!") # Filter mentions to see if the user can mention members/roles - if mentions: - mentions_allowed, disallowed_mentions = await self.check_mentions(ctx, mentions) - if not mentions_allowed: - return await send_denial( - ctx, f"You can't mention other {disallowed_mentions} in your reminder!" - ) + if not await self.validate_mentions(ctx, mentions): + return mention_ids = [mention.id for mention in mentions] @@ -361,14 +372,10 @@ class Reminders(Cog): async def edit_reminder_mentions(self, ctx: Context, id_: int, mentions: Greedy[Mentionable]) -> None: """Edit one of your reminder's mentions.""" # Filter mentions to see if the user can mention members/roles - mentions_allowed, disallowed_mentions = await self.check_mentions(ctx, mentions) - if not mentions_allowed: - return await send_denial( - ctx, f"You can't mention other {disallowed_mentions} in your reminder!" - ) + if not await self.validate_mentions(ctx, mentions): + return mention_ids = [mention.id for mention in mentions] - await self.edit_reminder(ctx, id_, {"mentions": mention_ids}) async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: -- cgit v1.2.3 From daff90ef6ff5de8c9f08b8394979da758e484001 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sun, 19 Jul 2020 22:39:12 +0800 Subject: Refactor commands return type --- bot/cogs/reminders.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index cc20897e0..1410bfea6 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -228,7 +228,7 @@ class Reminders(Cog): @remind_group.command(name="new", aliases=("add", "create")) async def new_reminder( self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str - ) -> t.Optional[discord.Message]: + ) -> None: """ Set yourself a simple reminder. @@ -239,7 +239,8 @@ class Reminders(Cog): # If they don't have permission to set a reminder in this channel if ctx.channel.id not in WHITELISTED_CHANNELS: - return await send_denial(ctx, "Sorry, you can't do that here!") + await send_denial(ctx, "Sorry, you can't do that here!") + return # Get their current active reminders active_reminders = await self.bot.api_client.get( @@ -252,7 +253,8 @@ class Reminders(Cog): # Let's limit this, so we don't get 10 000 # reminders from kip or something like that :P if len(active_reminders) > MAXIMUM_REMINDERS: - return await send_denial(ctx, "You have too many active reminders!") + await send_denial(ctx, "You have too many active reminders!") + return # Filter mentions to see if the user can mention members/roles if not await self.validate_mentions(ctx, mentions): @@ -291,7 +293,7 @@ class Reminders(Cog): self.schedule_reminder(reminder) @remind_group.command(name="list") - async def list_reminders(self, ctx: Context) -> t.Optional[discord.Message]: + async def list_reminders(self, ctx: Context) -> None: """View a paginated embed of all reminders for your user.""" # Get all the user's reminders from the database. data = await self.bot.api_client.get( @@ -337,7 +339,8 @@ class Reminders(Cog): # Remind the user that they have no reminders :^) if not lines: embed.description = "No active reminders could be found." - return await ctx.send(embed=embed) + await ctx.send(embed=embed) + return # Construct the embed and paginate it. embed.colour = discord.Colour.blurple() -- cgit v1.2.3 From d658fabaa1238eda9d26175af751e2e8b7f1fc13 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sun, 19 Jul 2020 22:51:51 +0800 Subject: Remove duplicate mentions from reminder arguments This also accounts for the author passing themselves to mention, and therefore avoids the user from being told they're not allowed to mention themselves even though they could. --- bot/cogs/reminders.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 1410bfea6..60fa70d74 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -104,7 +104,7 @@ class Reminders(Cog): await ctx.send(embed=embed) @staticmethod - async def _check_mentions(ctx: Context, mentions: t.List[Mentionable]) -> t.Tuple[bool, str]: + async def _check_mentions(ctx: Context, mentions: t.Iterable[Mentionable]) -> t.Tuple[bool, str]: """ Returns whether or not the list of mentions is allowed. @@ -121,7 +121,7 @@ class Reminders(Cog): return True, "" @staticmethod - async def validate_mentions(ctx: Context, mentions: t.List[Mentionable]) -> bool: + async def validate_mentions(ctx: Context, mentions: t.Iterable[Mentionable]) -> bool: """ Filter mentions to see if the user can mention, and sends a denial if not allowed. @@ -256,6 +256,10 @@ class Reminders(Cog): await send_denial(ctx, "You have too many active reminders!") return + # Remove duplicate mentions + mentions = set(mentions) + mentions.discard(ctx.author) + # Filter mentions to see if the user can mention members/roles if not await self.validate_mentions(ctx, mentions): return @@ -374,6 +378,10 @@ class Reminders(Cog): @edit_reminder_group.command(name="mentions", aliases=("pings",)) async def edit_reminder_mentions(self, ctx: Context, id_: int, mentions: Greedy[Mentionable]) -> None: """Edit one of your reminder's mentions.""" + # Remove duplicate mentions + mentions = set(mentions) + mentions.discard(ctx.author) + # Filter mentions to see if the user can mention members/roles if not await self.validate_mentions(ctx, mentions): return -- cgit v1.2.3 From 69e3a2e31e4c91c1932efe5a584708a3a370bb35 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sun, 19 Jul 2020 22:54:10 +0800 Subject: Revert "Remove duplicate reminder deletion." This reverts commit 776b4379c478284803a4a526b5f14fe63d8e7c01. This is already being fixed in #835, and therefore is no longer required. --- bot/cogs/reminders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 60fa70d74..219c52659 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -58,7 +58,6 @@ class Reminders(Cog): if remind_at < now: late = relativedelta(now, remind_at) await self.send_reminder(reminder, late) - await self._delete_reminder(reminder["id"]) else: self.schedule_reminder(reminder) @@ -217,6 +216,7 @@ class Reminders(Cog): content=f"{user.mention} {additional_mentions}", embed=embed ) + await self._delete_reminder(reminder["id"]) @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True) async def remind_group( -- cgit v1.2.3 From f99d27074e8088f7ea8abe4957321490875aa249 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 19 Jul 2020 19:39:20 +0200 Subject: Validation of guild invites. We will now validate and convert any standard discord server invite to a guild ID, and automatically add the name of the server as a comment. This will ensure that the list of whitelisted guild IDs will be readable and nice. This also makes minor changes to list output aesthetics. --- bot/cogs/allow_deny_lists.py | 29 +++++++++++++++++++++++++---- bot/cogs/filtering.py | 14 ++------------ bot/converters.py | 40 +++++++++++++++++++++++++++++++++++++++- bot/utils/regex.py | 12 ++++++++++++ 4 files changed, 78 insertions(+), 17 deletions(-) create mode 100644 bot/utils/regex.py diff --git a/bot/cogs/allow_deny_lists.py b/bot/cogs/allow_deny_lists.py index 8b3c892f5..d82d175cf 100644 --- a/bot/cogs/allow_deny_lists.py +++ b/bot/cogs/allow_deny_lists.py @@ -7,7 +7,7 @@ from discord.ext.commands import BadArgument, Cog, Context, group from bot import constants from bot.api import ResponseCodeError from bot.bot import Bot -from bot.converters import ValidAllowDenyListType +from bot.converters import ValidAllowDenyListType, ValidDiscordServerInvite from bot.pagination import LinePaginator from bot.utils.checks import with_role_check @@ -31,6 +31,24 @@ class AllowDenyLists(Cog): """Add an item to an allow or denylist.""" allow_type = "whitelist" if allowed else "blacklist" + # If this is a server invite, we gotta validate it. + if list_type == "GUILD_INVITE": + log.trace(f"{content} is a guild invite, attempting to validate.") + validator = ValidDiscordServerInvite() + guild_data = await validator.convert(ctx, content) + + # If we make it this far without raising a BadArgument, the invite is + # valid. Let's convert the content to an ID. + log.trace(f"{content} validated as server invite. Converting to ID.") + content = guild_data.get("id") + + # Unless the user has specified another comment, let's + # use the server name as the comment so that the list + # of guild IDs will be more easily readable when we + # display it. + if not comment: + comment = guild_data.get("name") + # Try to add the item to the database log.trace(f"Trying to add the {content} item to the {list_type} {allow_type}") payload = { @@ -100,17 +118,18 @@ class AllowDenyLists(Cog): # Build a list of lines we want to show in the paginator lines = [] for item in result: - line = f"• {item.get('content')}" + line = f"• `{item.get('content')}`" if item.get("comment"): - line += f" ({item.get('comment')})" + line += f" - {item.get('comment')}" lines.append(line) lines = sorted(lines) # Build the embed + list_type_plural = list_type.lower().replace("_", " ").title() + "s" embed = Embed( - title=f"{allow_type.title()}ed {list_type.lower()} items ({len(result)} total)", + title=f"{allow_type.title()}ed {list_type_plural} ({len(result)} total)", colour=Colour.blue() ) log.trace(f"Trying to list {len(result)} items from the {list_type.lower()} {allow_type}") @@ -139,6 +158,7 @@ class AllowDenyLists(Cog): ctx: Context, list_type: ValidAllowDenyListType, content: str, + *, comment: Optional[str] = None, ) -> None: """Add an item to the specified allowlist.""" @@ -150,6 +170,7 @@ class AllowDenyLists(Cog): ctx: Context, list_type: ValidAllowDenyListType, content: str, + *, comment: Optional[str] = None, ) -> None: """Add an item to the specified denylist.""" diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 4d51bba2e..3ebb47a0f 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -18,22 +18,12 @@ from bot.constants import ( Filter, Icons, URLs ) from bot.utils.redis_cache import RedisCache +from bot.utils.regex import INVITE_RE from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) # Regular expressions -INVITE_RE = re.compile( - r"(?:discord(?:[\.,]|dot)gg|" # Could be discord.gg/ - r"discord(?:[\.,]|dot)com(?:\/|slash)invite|" # or discord.com/invite/ - r"discordapp(?:[\.,]|dot)com(?:\/|slash)invite|" # or discordapp.com/invite/ - r"discord(?:[\.,]|dot)me|" # or discord.me - r"discord(?:[\.,]|dot)io" # or discord.io. - r")(?:[\/]|slash)" # / or 'slash' - r"([a-zA-Z0-9]+)", # the invite code itself - flags=re.IGNORECASE -) - SPOILER_RE = re.compile(r"(\|\|.+?\|\|)", re.DOTALL) URL_RE = re.compile(r"(https?://[^\s]+)", flags=re.IGNORECASE) ZALGO_RE = re.compile(r"[\u0300-\u036F\u0489]") @@ -478,7 +468,7 @@ class Filtering(Cog): return True guild_id = guild.get("id") - guild_invite_whitelist = self._get_allowlist_items(True, "guild_invite_id") + guild_invite_whitelist = self._get_allowlist_items(True, "guild_invite") if guild_id not in guild_invite_whitelist: guild_icon_hash = guild["icon"] diff --git a/bot/converters.py b/bot/converters.py index edac67be2..7e21c1542 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -9,8 +9,10 @@ import dateutil.tz import discord from aiohttp import ClientConnectorError, ContentTypeError from dateutil.relativedelta import relativedelta -from discord.ext.commands import BadArgument, Context, Converter, UserConverter +from discord.ext.commands import BadArgument, Context, Converter, IDConverter, UserConverter +from bot.constants import URLs +from bot.utils.regex import INVITE_RE log = logging.getLogger(__name__) @@ -34,6 +36,42 @@ def allowed_strings(*values, preserve_case: bool = False) -> t.Callable[[str], s return converter +class ValidDiscordServerInvite(Converter): + """ + A converter that validates whether a given string is a valid Discord server invite. + + Raises 'BadArgument' if: + - The string is not a valid Discord server invite. + - The string is valid, but is an invite for a group DM. + - The string is valid, but is expired. + + Returns a (partial) guild object if: + - The string is a valid vanity + - The string is a full invite URI + - The string contains the invite code (the stuff after discord.gg/) + + See the Discord API docs for documentation on the guild object: + https://discord.com/developers/docs/resources/guild#guild-object + """ + + async def convert(self, ctx: Context, server_invite: str) -> dict: + """Check whether the string is a valid Discord server invite.""" + invite_code = INVITE_RE.match(server_invite) + if invite_code: + response = await ctx.bot.http_session.get( + f"{URLs.discord_invite_api}/{invite_code[1]}" + ) + if response.status != 404: + invite_data = await response.json() + return invite_data.get("guild") + + id_converter = IDConverter() + if id_converter._get_id_match(server_invite): + raise BadArgument("Guild IDs are not supported, only invites.") + + raise BadArgument("This does not appear to be a valid Discord server invite.") + + class ValidAllowDenyListType(Converter): """ A converter that checks whether the given string is a valid AllowDenyList type. diff --git a/bot/utils/regex.py b/bot/utils/regex.py new file mode 100644 index 000000000..d194f93cb --- /dev/null +++ b/bot/utils/regex.py @@ -0,0 +1,12 @@ +import re + +INVITE_RE = re.compile( + r"(?:discord(?:[\.,]|dot)gg|" # Could be discord.gg/ + r"discord(?:[\.,]|dot)com(?:\/|slash)invite|" # or discord.com/invite/ + r"discordapp(?:[\.,]|dot)com(?:\/|slash)invite|" # or discordapp.com/invite/ + r"discord(?:[\.,]|dot)me|" # or discord.me + r"discord(?:[\.,]|dot)io" # or discord.io. + r")(?:[\/]|slash)" # / or 'slash' + r"([a-zA-Z0-9]+)", # the invite code itself + flags=re.IGNORECASE +) -- cgit v1.2.3 From f771222df165d90aff0f2d9d44bd9ba86b265574 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 19 Jul 2020 19:57:40 +0200 Subject: Validation of guild invites for delete. We want to support deletion of both IDs and guild invites, so we need a bit of special handling for that. --- bot/cogs/allow_deny_lists.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/bot/cogs/allow_deny_lists.py b/bot/cogs/allow_deny_lists.py index d82d175cf..71a032ea5 100644 --- a/bot/cogs/allow_deny_lists.py +++ b/bot/cogs/allow_deny_lists.py @@ -2,7 +2,7 @@ import logging from typing import Optional from discord import Colour, Embed -from discord.ext.commands import BadArgument, Cog, Context, group +from discord.ext.commands import BadArgument, Cog, Context, IDConverter, group from bot import constants from bot.api import ResponseCodeError @@ -95,9 +95,21 @@ class AllowDenyLists(Cog): """Remove an item from an allow or denylist.""" item = None allow_type = "whitelist" if allowed else "blacklist" + id_converter = IDConverter() - log.trace(f"Trying to delete the {content} item from the {list_type} {allow_type}") + # If this is a server invite, we need to convert it. + if list_type == "GUILD_INVITE" and not id_converter._get_id_match(content): + log.trace(f"{content} is a guild invite, attempting to validate.") + validator = ValidDiscordServerInvite() + guild_data = await validator.convert(ctx, content) + + # If we make it this far without raising a BadArgument, the invite is + # valid. Let's convert the content to an ID. + log.trace(f"{content} validated as server invite. Converting to ID.") + content = guild_data.get("id") + # Find the content and delete it. + log.trace(f"Trying to delete the {content} item from the {list_type} {allow_type}") for allow_list in self.bot.allow_deny_list_cache.get(f"{list_type}.{allowed}", []): if content == allow_list.get("content"): item = allow_list -- cgit v1.2.3 From 73b12fa63877a26bfe324e968f00337969f1f6cf Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 19 Jul 2020 20:44:51 +0200 Subject: Implement new guild invite filtering logic. We now filter guild invites the following way: - Whitelisted invites are always permitted. - Blacklisted invites are never permitted. - If the invite is not blacklisted, it is permitted only if it is a Verified or a Partnered server, otherwise not. This strategy was decided on during the June 7th staff meeting, see https://github.com/python-discord/organisation/issues/261 --- bot/cogs/filtering.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 3ebb47a0f..b5b1c823a 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -99,9 +99,9 @@ class Filtering(Cog): self.bot.loop.create_task(self.reschedule_offensive_msg_deletion()) - def _get_allowlist_items(self, allow: bool, list_type: str, compiled: Optional[bool] = False) -> list: + def _get_allowlist_items(self, list_type: str, *, allowed: bool, compiled: Optional[bool] = False) -> list: """Fetch items from the allow_deny_list_cache.""" - items = self.bot.allow_deny_list_cache.get(f"{list_type.upper()}.{allow}", []) + items = self.bot.allow_deny_list_cache.get(f"{list_type.upper()}.{allowed}", []) if compiled: return [re.compile(fr'{item.get("content")}', flags=re.IGNORECASE) for item in items] @@ -143,7 +143,7 @@ class Filtering(Cog): def get_name_matches(self, name: str) -> List[re.Match]: """Check bad words from passed string (name). Return list of matches.""" matches = [] - watchlist_patterns = self._get_allowlist_items(False, 'word_watchlist', compiled=True) + watchlist_patterns = self._get_allowlist_items('word_watchlist', allowed=False, compiled=True) for pattern in watchlist_patterns: if match := pattern.search(name): matches.append(match) @@ -408,7 +408,7 @@ class Filtering(Cog): if URL_RE.search(text): return False - watchlist_patterns = self._get_allowlist_items(False, 'word_watchlist', compiled=True) + watchlist_patterns = self._get_allowlist_items('word_watchlist', allowed=False, compiled=True) for pattern in watchlist_patterns: match = pattern.search(text) if match: @@ -420,7 +420,7 @@ class Filtering(Cog): return False text = text.lower() - domain_blacklist = self._get_allowlist_items(False, "domain_name") + domain_blacklist = self._get_allowlist_items("domain_name", allowed=False) for url in domain_blacklist: if url.lower() in text: @@ -468,9 +468,21 @@ class Filtering(Cog): return True guild_id = guild.get("id") - guild_invite_whitelist = self._get_allowlist_items(True, "guild_invite") + guild_invite_whitelist = self._get_allowlist_items("guild_invite", allowed=True) + guild_invite_blacklist = self._get_allowlist_items("guild_invite", allowed=False) - if guild_id not in guild_invite_whitelist: + # Is this invite allowed? + guild_partnered_or_verified = ( + 'PARTNERED' in guild.get("features") + or 'VERIFIED' in guild.get("features") + ) + invite_not_allowed = ( + guild_id in guild_invite_blacklist # Blacklisted guilds are never permitted. + or guild_id not in guild_invite_whitelist # Whitelisted guilds are always permitted. + and not guild_partnered_or_verified # Otherwise guilds have to be Verified or Partnered. + ) + + if invite_not_allowed: guild_icon_hash = guild["icon"] guild_icon = ( "https://cdn.discordapp.com/icons/" -- cgit v1.2.3 From 10b17a5026489c6fa28ed93edef340e4cacdc8c3 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Mon, 20 Jul 2020 14:58:00 +0100 Subject: Removed python formatting from returned codeblock --- bot/cogs/snekbox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 662f90869..52c8b6f88 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -202,7 +202,7 @@ class Snekbox(Cog): output, paste_link = await self.format_output(results["stdout"]) icon = self.get_status_emoji(results) - msg = f"{ctx.author.mention} {icon} {msg}.\n\n```py\n{output}\n```" + msg = f"{ctx.author.mention} {icon} {msg}.\n\n```\n{output}\n```" if paste_link: msg = f"{msg}\nFull output: {paste_link}" -- cgit v1.2.3 From e93fdaf57d1d35394b466a6bd1c84712e29415d7 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Mon, 20 Jul 2020 16:05:32 +0100 Subject: Edited tests to reflect changes (removed py formatting) --- tests/bot/cogs/test_snekbox.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 98dee7a1b..343e37db9 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -239,7 +239,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): await self.cog.send_eval(ctx, 'MyAwesomeCode') ctx.send.assert_called_once_with( - '@LemonLemonishBeard#0042 :yay!: Return code 0.\n\n```py\n[No output]\n```' + '@LemonLemonishBeard#0042 :yay!: Return code 0.\n\n```\n[No output]\n```' ) self.cog.post_eval.assert_called_once_with('MyAwesomeCode') self.cog.get_status_emoji.assert_called_once_with({'stdout': '', 'returncode': 0}) @@ -265,7 +265,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): await self.cog.send_eval(ctx, 'MyAwesomeCode') ctx.send.assert_called_once_with( '@LemonLemonishBeard#0042 :yay!: Return code 0.' - '\n\n```py\nWay too long beard\n```\nFull output: lookatmybeard.com' + '\n\n```\nWay too long beard\n```\nFull output: lookatmybeard.com' ) self.cog.post_eval.assert_called_once_with('MyAwesomeCode') self.cog.get_status_emoji.assert_called_once_with({'stdout': 'Way too long beard', 'returncode': 0}) @@ -289,7 +289,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): await self.cog.send_eval(ctx, 'MyAwesomeCode') ctx.send.assert_called_once_with( - '@LemonLemonishBeard#0042 :nope!: Return code 127.\n\n```py\nBeard got stuck in the eval\n```' + '@LemonLemonishBeard#0042 :nope!: Return code 127.\n\n```\nBeard got stuck in the eval\n```' ) self.cog.post_eval.assert_called_once_with('MyAwesomeCode') self.cog.get_status_emoji.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127}) -- cgit v1.2.3 From b6f11f518acc29cf0bc025f2c89005556a06553a Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 21 Jul 2020 01:26:09 +0100 Subject: Use max_units for time since join in user command instead of precision --- bot/cogs/information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index f0bd1afdb..d6090d481 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -226,7 +226,7 @@ class Information(Cog): if user.nick: name = f"{user.nick} ({name})" - joined = time_since(user.joined_at, precision="days") + joined = time_since(user.joined_at, max_units=3) roles = ", ".join(role.mention for role in user.roles[1:]) description = [ -- cgit v1.2.3 From b1777fb0d93ce329c7dc4120d510ebc81ede2920 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 20 Jul 2020 09:30:26 -0700 Subject: Clean up imports --- bot/utils/messages.py | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 670289941..63bda877c 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -6,8 +6,7 @@ import re from io import BytesIO from typing import List, Optional, Sequence, Union -from discord import Client, Colour, Embed, File, Member, Message, Reaction, TextChannel, Webhook -from discord.abc import Snowflake +import discord from discord.errors import HTTPException from discord.ext.commands import Context @@ -17,12 +16,12 @@ log = logging.getLogger(__name__) async def wait_for_deletion( - message: Message, - user_ids: Sequence[Snowflake], + message: discord.Message, + user_ids: Sequence[discord.abc.Snowflake], deletion_emojis: Sequence[str] = (Emojis.trashcan,), timeout: float = 60 * 5, attach_emojis: bool = True, - client: Optional[Client] = None + client: Optional[discord.Client] = None ) -> None: """ Wait for up to `timeout` seconds for a reaction by any of the specified `user_ids` to delete the message. @@ -42,7 +41,7 @@ async def wait_for_deletion( for emoji in deletion_emojis: await message.add_reaction(emoji) - def check(reaction: Reaction, user: Member) -> bool: + def check(reaction: discord.Reaction, user: discord.Member) -> bool: """Check that the deletion emoji is reacted by the appropriate user.""" return ( reaction.message.id == message.id @@ -56,8 +55,8 @@ async def wait_for_deletion( async def send_attachments( - message: Message, - destination: Union[TextChannel, Webhook], + message: discord.Message, + destination: Union[discord.TextChannel, discord.Webhook], link_large: bool = True ) -> List[str]: """ @@ -81,9 +80,9 @@ async def send_attachments( if attachment.size <= destination.guild.filesize_limit - 512: with BytesIO() as file: await attachment.save(file, use_cached=True) - attachment_file = File(file, filename=attachment.filename) + attachment_file = discord.File(file, filename=attachment.filename) - if isinstance(destination, TextChannel): + if isinstance(destination, discord.TextChannel): msg = await destination.send(file=attachment_file) urls.append(msg.attachments[0].url) else: @@ -104,10 +103,10 @@ async def send_attachments( if link_large and large: desc = "\n".join(f"[{attachment.filename}]({attachment.url})" for attachment in large) - embed = Embed(description=desc) + embed = discord.Embed(description=desc) embed.set_footer(text="Attachments exceed upload size limit.") - if isinstance(destination, TextChannel): + if isinstance(destination, discord.TextChannel): await destination.send(embed=embed) else: await destination.send( @@ -138,8 +137,8 @@ def sub_clyde(username: Optional[str]) -> Optional[str]: async def send_denial(ctx: Context, reason: str) -> None: """Send an embed denying the user with the given reason.""" - embed = Embed() - embed.colour = Colour.red() + embed = discord.Embed() + embed.colour = discord.Colour.red() embed.title = random.choice(NEGATIVE_REPLIES) embed.description = reason -- cgit v1.2.3 From d35c603c8fb252335d58451d2310fd2b55585e22 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 20 Jul 2020 10:31:41 -0700 Subject: Add util function to format user names This will be used a lot when sending mod logs and will help with reducing redundancy and maintaining consistency. --- bot/utils/messages.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 63bda877c..31825d4a7 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -9,6 +9,7 @@ from typing import List, Optional, Sequence, Union import discord from discord.errors import HTTPException from discord.ext.commands import Context +from discord.utils import escape_markdown from bot.constants import Emojis, NEGATIVE_REPLIES @@ -143,3 +144,9 @@ async def send_denial(ctx: Context, reason: str) -> None: embed.description = reason await ctx.send(embed=embed) + + +def format_user(user: discord.abc.User) -> str: + """Return a string for `user` which has their mention and name#discriminator.""" + name = escape_markdown(str(user)) + return f"{user.mention} ({name})" -- cgit v1.2.3 From 090b42d0c46957091681bc8f4666523513ccdc28 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 20 Jul 2020 11:55:02 -0700 Subject: Superstarify: use user mentions in mod logs `format_user` isn't used in the apply mod log cause it already shows both the old and new nicknames elsewhere. --- bot/cogs/moderation/superstarify.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 867de815a..f22e7e741 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -12,6 +12,7 @@ from bot import constants from bot.bot import Bot from bot.converters import Expiry from bot.utils.checks import with_role_check +from bot.utils.messages import format_user from bot.utils.time import format_infraction from . import utils from .scheduler import InfractionScheduler @@ -181,8 +182,8 @@ class Superstarify(InfractionScheduler, Cog): title="Member achieved superstardom", thumbnail=member.avatar_url_as(static_format="png"), text=textwrap.dedent(f""" - Member: {member.mention} (`{member.id}`) - Actor: {ctx.message.author} + Member: {member.mention} + Actor: {ctx.message.author.mention} Expires: {expiry_str} Old nickname: `{old_nick}` New nickname: `{forced_nick}` @@ -221,7 +222,7 @@ class Superstarify(InfractionScheduler, Cog): ) return { - "Member": f"{user.mention}(`{user.id}`)", + "Member": format_user(user), "DM": "Sent" if notified else "**Failed**" } -- cgit v1.2.3 From 830e231b681315b74480a312111e5cabc5ca2167 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 20 Jul 2020 11:55:45 -0700 Subject: Superstarify: escape Markdown in nicknames --- bot/cogs/moderation/superstarify.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index f22e7e741..b23588b1c 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -7,6 +7,7 @@ from pathlib import Path from discord import Colour, Embed, Member from discord.ext.commands import Cog, Context, command +from discord.utils import escape_markdown from bot import constants from bot.bot import Bot @@ -139,7 +140,6 @@ class Superstarify(InfractionScheduler, Cog): infraction = await utils.post_infraction(ctx, member, "superstar", reason, duration, active=True) id_ = infraction["id"] - old_nick = member.display_name forced_nick = self.get_nick(id_, member.id) expiry_str = format_infraction(infraction["expires_at"]) @@ -149,6 +149,9 @@ class Superstarify(InfractionScheduler, Cog): await member.edit(nick=forced_nick, reason=reason) self.schedule_expiration(infraction) + old_nick = escape_markdown(member.display_name) + forced_nick = escape_markdown(forced_nick) + # Send a DM to the user to notify them of their new infraction. await utils.notify_infraction( user=member, -- cgit v1.2.3 From dfd2d8c1d0a21d92ea0a0875650c814e6fbbff4f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 20 Jul 2020 12:06:31 -0700 Subject: Moderation: remove multiple active infractions check The API was change a long time ago to not allow such a situation. --- bot/cogs/moderation/scheduler.py | 36 ------------------------------------ 1 file changed, 36 deletions(-) diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 601e238c9..0b0bc9eb7 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -243,42 +243,6 @@ class InfractionScheduler: id_ = response[0]['id'] footer = f"ID: {id_}" - # If multiple active infractions were found, mark them as inactive in the database - # and cancel their expiration tasks. - if len(response) > 1: - log.info( - f"Found more than one active {infr_type} infraction for user {user.id}; " - "deactivating the extra active infractions too." - ) - - footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}" - - log_note = f"Found multiple **active** {infr_type} infractions in the database." - if "Note" in log_text: - log_text["Note"] = f" {log_note}" - else: - log_text["Note"] = log_note - - # deactivate_infraction() is not called again because: - # 1. Discord cannot store multiple active bans or assign multiples of the same role - # 2. It would send a pardon DM for each active infraction, which is redundant - for infraction in response[1:]: - id_ = infraction['id'] - try: - # Mark infraction as inactive in the database. - await self.bot.api_client.patch( - f"bot/infractions/{id_}", - json={"active": False} - ) - except ResponseCodeError: - log.exception(f"Failed to deactivate infraction #{id_} ({infr_type})") - # This is simpler and cleaner than trying to concatenate all the errors. - log_text["Failure"] = "See bot's logs for details." - - # Cancel pending expiration task. - if infraction["expires_at"] is not None: - self.scheduler.cancel(infraction["id"]) - # Accordingly display whether the user was successfully notified via DM. dm_emoji = "" if log_text.get("DM") == "Sent": -- cgit v1.2.3 From b5dce5b6d5ee096a3fd4daec0e72405159aee87d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 20 Jul 2020 14:44:04 -0700 Subject: Moderation: use user mentions in mod logs --- bot/cogs/moderation/infractions.py | 3 ++- bot/cogs/moderation/management.py | 14 +++++--------- bot/cogs/moderation/scheduler.py | 15 +++++++-------- 3 files changed, 14 insertions(+), 18 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 8df642428..5404991e8 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -13,6 +13,7 @@ from bot.constants import Event from bot.converters import Expiry, FetchedMember from bot.decorators import respect_role_hierarchy from bot.utils.checks import with_role_check +from bot.utils.messages import format_user from . import utils from .scheduler import InfractionScheduler from .utils import UserSnowflake @@ -316,7 +317,7 @@ class Infractions(InfractionScheduler, commands.Cog): icon_url=utils.INFRACTION_ICONS["mute"][1] ) - log_text["Member"] = f"{user.mention}(`{user.id}`)" + log_text["Member"] = format_user(user) log_text["DM"] = "Sent" if notified else "**Failed**" else: log.info(f"Failed to unmute user {user_id}: user not found") diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 672bb0e9c..e0a86ee59 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -11,7 +11,7 @@ from bot import constants from bot.bot import Bot from bot.converters import Expiry, InfractionSearchQuery, allowed_strings, proxy_user from bot.pagination import LinePaginator -from bot.utils import time +from bot.utils import messages, time from bot.utils.checks import in_whitelist_check, with_role_check from . import utils from .infractions import Infractions @@ -154,16 +154,12 @@ class ModManagement(commands.Cog): user = ctx.guild.get_member(user_id) if user: - user_text = f"{user.mention} (`{user.id}`)" + user_text = messages.format_user(user) thumbnail = user.avatar_url_as(static_format="png") else: - user_text = f"`{user_id}`" + user_text = f"<@{user_id}>" thumbnail = None - # The infraction's actor - actor_id = new_infraction['actor'] - actor = ctx.guild.get_member(actor_id) or f"`{actor_id}`" - await self.mod_log.send_log_message( icon_url=constants.Icons.pencil, colour=discord.Colour.blurple(), @@ -171,8 +167,8 @@ class ModManagement(commands.Cog): thumbnail=thumbnail, text=textwrap.dedent(f""" Member: {user_text} - Actor: {actor} - Edited by: {ctx.message.author}{log_text} + Actor: <@{new_infraction['actor']}> + Edited by: {ctx.message.author.mention}{log_text} """) ) diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 0b0bc9eb7..6323bd55a 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -13,8 +13,7 @@ from bot import constants from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Colours, STAFF_CHANNELS -from bot.utils import time -from bot.utils.scheduling import Scheduler +from bot.utils import messages, scheduling, time from . import utils from .modlog import ModLog from .utils import UserSnowflake @@ -27,7 +26,7 @@ class InfractionScheduler: def __init__(self, bot: Bot, supported_infractions: t.Container[str]): self.bot = bot - self.scheduler = Scheduler(self.__class__.__name__) + self.scheduler = scheduling.Scheduler(self.__class__.__name__) self.bot.loop.create_task(self.reschedule_infractions(supported_infractions)) @@ -193,8 +192,8 @@ class InfractionScheduler: title=f"Infraction {log_title}: {infr_type}", thumbnail=user.avatar_url_as(static_format="png"), text=textwrap.dedent(f""" - Member: {user.mention} (`{user.id}`) - Actor: {ctx.message.author}{dm_log_text}{expiry_log_text} + Member: {messages.format_user(user)} + Actor: {ctx.message.author.mention}{dm_log_text}{expiry_log_text} Reason: {reason} """), content=log_content, @@ -237,8 +236,8 @@ class InfractionScheduler: # Deactivate the infraction and cancel its scheduled expiration task. log_text = await self.deactivate_infraction(response[0], send_log=False) - log_text["Member"] = f"{user.mention}(`{user.id}`)" - log_text["Actor"] = str(ctx.message.author) + log_text["Member"] = messages.format_user(user) + log_text["Actor"] = ctx.message.author.mention log_content = None id_ = response[0]['id'] footer = f"ID: {id_}" @@ -316,7 +315,7 @@ class InfractionScheduler: log_content = None log_text = { "Member": f"<@{user_id}>", - "Actor": str(self.bot.get_user(actor) or actor), + "Actor": f"<@{actor}>", "Reason": infraction["reason"], "Created": created, } -- cgit v1.2.3 From 1a87af51c2484b455e546af976f2a427c9134e72 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 20 Jul 2020 14:10:22 -0700 Subject: Use user mentions in mod logs --- bot/cogs/antispam.py | 4 ++-- bot/cogs/clean.py | 3 ++- bot/cogs/defcon.py | 3 ++- bot/cogs/token_remover.py | 6 +++--- bot/cogs/verification.py | 3 ++- bot/cogs/webhook_remover.py | 5 +++-- 6 files changed, 14 insertions(+), 10 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 0bcca578d..e6fcb079c 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -19,7 +19,7 @@ from bot.constants import ( STAFF_ROLES, ) from bot.converters import Duration -from bot.utils.messages import send_attachments +from bot.utils.messages import format_user, send_attachments log = logging.getLogger(__name__) @@ -67,7 +67,7 @@ class DeletionContext: async def upload_messages(self, actor_id: int, modlog: ModLog) -> None: """Method that takes care of uploading the queue and posting modlog alert.""" - triggered_by_users = ", ".join(f"{m} (`{m.id}`)" for m in self.members.values()) + triggered_by_users = ", ".join(format_user(m) for m in self.members.values()) mod_alert_message = ( f"**Triggered by:** {triggered_by_users}\n" diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index f436e531a..c36ff3aba 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -179,7 +179,8 @@ class Clean(Cog): target_channels = ", ".join(channel.mention for channel in channels) message = ( - f"**{len(message_ids)}** messages deleted in {target_channels} by **{ctx.author.name}**\n\n" + f"**{len(message_ids)}** messages deleted in {target_channels} by " + f"{ctx.author.name.mention}\n\n" f"A log of the deleted messages can be found [here]({log_url})." ) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 4c0ad5914..a7dd4670e 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -12,6 +12,7 @@ from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.constants import Channels, Colours, Emojis, Event, Icons, Roles from bot.decorators import with_role +from bot.utils.messages import format_user log = logging.getLogger(__name__) @@ -107,7 +108,7 @@ class Defcon(Cog): self.bot.stats.incr("defcon.leaves") message = ( - f"{member} (`{member.id}`) was denied entry because their account is too new." + f"{format_user(member)} was denied entry because their account is too new." ) if not message_sent: diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index ef979f222..67d6918ab 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -11,11 +11,12 @@ from bot import utils from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.constants import Channels, Colours, Event, Icons +from bot.utils.messages import format_user log = logging.getLogger(__name__) LOG_MESSAGE = ( - "Censored a seemingly valid token sent by {author} (`{author_id}`) in {channel}, " + "Censored a seemingly valid token sent by {author} in {channel}, " "token was `{user_id}.{timestamp}.{hmac}`" ) DELETION_MESSAGE_TEMPLATE = ( @@ -111,8 +112,7 @@ class TokenRemover(Cog): def format_log_message(msg: Message, token: Token) -> str: """Return the log message to send for `token` being censored in `msg`.""" return LOG_MESSAGE.format( - author=msg.author, - author_id=msg.author.id, + author=format_user(msg.author), channel=msg.channel.mention, user_id=token.user_id, timestamp=token.timestamp, diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index ae156cf70..f4cdc7059 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -9,6 +9,7 @@ from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.decorators import in_whitelist, without_role from bot.utils.checks import InWhitelistCheckFailure, without_role_check +from bot.utils.messages import format_user log = logging.getLogger(__name__) @@ -66,7 +67,7 @@ class Verification(Cog): ) embed_text = ( - f"{message.author.mention} sent a message in " + f"{format_user(message.author)} sent a message in " f"{message.channel.mention} that contained user and/or role mentions." f"\n\n**Original message:**\n>>> {message.content}" ) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index 543869215..b70e29a79 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -7,6 +7,7 @@ from discord.ext.commands import Cog from bot.bot import Bot from bot.cogs.moderation.modlog import ModLog from bot.constants import Channels, Colours, Event, Icons +from bot.utils.messages import format_user WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discordapp\.com/api/webhooks/\d+/)\S+/?", re.I) @@ -45,8 +46,8 @@ class WebhookRemover(Cog): await msg.channel.send(ALERT_MESSAGE_TEMPLATE.format(user=msg.author.mention)) message = ( - f"{msg.author} (`{msg.author.id}`) posted a Discord webhook URL " - f"to #{msg.channel}. Webhook URL was `{redacted_url}`" + f"{format_user(msg.author)} posted a Discord webhook URL to {msg.channel.mention}. " + f"Webhook URL was `{redacted_url}`" ) log.debug(message) -- cgit v1.2.3 From e261c608fb139baa4f99fb2be23bd3e928919107 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 20 Jul 2020 15:26:22 -0700 Subject: Filtering: refactor sending of mod log A lot of redundant code existed between the message and eval filters. --- bot/cogs/filtering.py | 106 ++++++++++++++++++++++---------------------------- 1 file changed, 47 insertions(+), 59 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index bd665f424..019a7bde2 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -221,36 +221,7 @@ class Filtering(Cog): if _filter["type"] == "filter": filter_triggered = True - # We do not have to check against DM channels since !eval cannot be used there. - channel_str = f"in {msg.channel.mention}" - - message_content, additional_embeds, additional_embeds_msg = self._add_stats( - filter_name, match, result - ) - - message = ( - f"The {filter_name} {_filter['type']} was triggered " - f"by **{msg.author}** " - f"(`{msg.author.id}`) {channel_str} using !eval with " - f"[the following message]({msg.jump_url}):\n\n" - f"{message_content}" - ) - - log.debug(message) - - # Send pretty mod log embed to mod-alerts - await self.mod_log.send_log_message( - icon_url=Icons.filtering, - colour=Colour(Colours.soft_red), - title=f"{_filter['type'].title()} triggered!", - text=message, - thumbnail=msg.author.avatar_url_as(static_format="png"), - channel_id=Channels.mod_alerts, - ping_everyone=Filter.ping_everyone, - additional_embeds=additional_embeds, - additional_embeds_msg=additional_embeds_msg - ) - + await self._send_log(filter_name, _filter["type"], match, msg, result) break # We don't want multiple filters to trigger return filter_triggered @@ -312,39 +283,56 @@ class Filtering(Cog): self.schedule_msg_delete(data) log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}") - if is_private: - channel_str = "via DM" - else: - channel_str = f"in {msg.channel.mention}" + await self._send_log(filter_name, _filter["type"], match, msg) + break # We don't want multiple filters to trigger - message_content, additional_embeds, additional_embeds_msg = self._add_stats( - filter_name, match, msg.content - ) + async def _send_log( + self, + filter_name: str, + filter_type: str, + match: Union[re.Match, dict, bool, List[discord.Embed]], + msg: discord.Message, + eval_content: Optional[str] = None + ) -> None: + """Send a mod log for a triggered filter.""" + if msg.channel.type is discord.ChannelType.private: + channel_str = "via DM" + else: + channel_str = f"in {msg.channel.mention}" - message = ( - f"The {filter_name} {_filter['type']} was triggered " - f"by **{msg.author}** " - f"(`{msg.author.id}`) {channel_str} with [the " - f"following message]({msg.jump_url}):\n\n" - f"{message_content}" - ) + if eval_content is None: + # It's not an eval, so use the message's contents to get stats. + eval_content = msg.content + else: + # This variable name is a bit misleading but whatever. + channel_str += " using !eval" - log.debug(message) - - # Send pretty mod log embed to mod-alerts - await self.mod_log.send_log_message( - icon_url=Icons.filtering, - colour=Colour(Colours.soft_red), - title=f"{_filter['type'].title()} triggered!", - text=message, - thumbnail=msg.author.avatar_url_as(static_format="png"), - channel_id=Channels.mod_alerts, - ping_everyone=Filter.ping_everyone, - additional_embeds=additional_embeds, - additional_embeds_msg=additional_embeds_msg - ) + message_content, additional_embeds, additional_embeds_msg = self._add_stats( + filter_name, match, eval_content + ) - break # We don't want multiple filters to trigger + message = ( + f"The {filter_name} {filter_type} was triggered " + f"by **{msg.author}** " + f"(`{msg.author.id}`) {channel_str} with [the " + f"following message]({msg.jump_url}):\n\n" + f"{message_content}" + ) + + log.debug(message) + + # Send pretty mod log embed to mod-alerts + await self.mod_log.send_log_message( + icon_url=Icons.filtering, + colour=Colour(Colours.soft_red), + title=f"{filter_type.title()} triggered!", + text=message, + thumbnail=msg.author.avatar_url_as(static_format="png"), + channel_id=Channels.mod_alerts, + ping_everyone=Filter.ping_everyone, + additional_embeds=additional_embeds, + additional_embeds_msg=additional_embeds_msg + ) def _add_stats(self, name: str, match: Union[re.Match, dict, bool, List[discord.Embed]], content: str) -> Tuple[ str, Optional[List[discord.Embed]], Optional[str] -- cgit v1.2.3 From 04fb8339ad18192636ddd14bf35ddf0295c71583 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 20 Jul 2020 15:43:31 -0700 Subject: Filtering: refactor _add_stats to return a NamedTuple --- bot/cogs/filtering.py | 52 ++++++++++++++++++++++++++------------------------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 019a7bde2..2118f03a2 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -2,7 +2,7 @@ import asyncio import logging import re from datetime import datetime, timedelta -from typing import List, Mapping, Optional, Tuple, Union +from typing import List, Mapping, NamedTuple, Optional, Union import dateutil import discord.errors @@ -46,6 +46,7 @@ TOKEN_WATCHLIST_PATTERNS = [ WATCHLIST_PATTERNS = WORD_WATCHLIST_PATTERNS + TOKEN_WATCHLIST_PATTERNS DAYS_BETWEEN_ALERTS = 3 +OFFENSIVE_MSG_DELETE_TIME = timedelta(days=Filter.offensive_msg_delete_days) def expand_spoilers(text: str) -> str: @@ -56,7 +57,15 @@ def expand_spoilers(text: str) -> str: ) -OFFENSIVE_MSG_DELETE_TIME = timedelta(days=Filter.offensive_msg_delete_days) +FilterMatch = Union[re.Match, dict, bool, List[discord.Embed]] + + +class Stats(NamedTuple): + """Additional stats on a triggered filter to append to a mod log.""" + + message_content: str + additional_embeds: Optional[List[discord.Embed]] + additional_embeds_msg: Optional[str] class Filtering(Cog): @@ -221,7 +230,9 @@ class Filtering(Cog): if _filter["type"] == "filter": filter_triggered = True - await self._send_log(filter_name, _filter["type"], match, msg, result) + stats = self._add_stats(filter_name, match, result) + await self._send_log(filter_name, _filter["type"], msg, stats, is_eval=True) + break # We don't want multiple filters to trigger return filter_triggered @@ -283,16 +294,19 @@ class Filtering(Cog): self.schedule_msg_delete(data) log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}") - await self._send_log(filter_name, _filter["type"], match, msg) + stats = self._add_stats(filter_name, match, msg.content) + await self._send_log(filter_name, _filter["type"], msg, stats) + break # We don't want multiple filters to trigger async def _send_log( self, filter_name: str, filter_type: str, - match: Union[re.Match, dict, bool, List[discord.Embed]], msg: discord.Message, - eval_content: Optional[str] = None + stats: Stats, + *, + is_eval: bool = False, ) -> None: """Send a mod log for a triggered filter.""" if msg.channel.type is discord.ChannelType.private: @@ -300,23 +314,13 @@ class Filtering(Cog): else: channel_str = f"in {msg.channel.mention}" - if eval_content is None: - # It's not an eval, so use the message's contents to get stats. - eval_content = msg.content - else: - # This variable name is a bit misleading but whatever. - channel_str += " using !eval" - - message_content, additional_embeds, additional_embeds_msg = self._add_stats( - filter_name, match, eval_content - ) - + eval_msg = "using !eval" if is_eval else "" message = ( f"The {filter_name} {filter_type} was triggered " f"by **{msg.author}** " - f"(`{msg.author.id}`) {channel_str} with [the " + f"(`{msg.author.id}`) {channel_str} {eval_msg}with [the " f"following message]({msg.jump_url}):\n\n" - f"{message_content}" + f"{stats.message_content}" ) log.debug(message) @@ -330,13 +334,11 @@ class Filtering(Cog): thumbnail=msg.author.avatar_url_as(static_format="png"), channel_id=Channels.mod_alerts, ping_everyone=Filter.ping_everyone, - additional_embeds=additional_embeds, - additional_embeds_msg=additional_embeds_msg + additional_embeds=stats.additional_embeds, + additional_embeds_msg=stats.additional_embeds_msg ) - def _add_stats(self, name: str, match: Union[re.Match, dict, bool, List[discord.Embed]], content: str) -> Tuple[ - str, Optional[List[discord.Embed]], Optional[str] - ]: + def _add_stats(self, name: str, match: FilterMatch, content: str) -> Stats: """Adds relevant statistical information to the relevant filter and increments the bot's stats.""" # Word and match stats for watch_regex if name == "watch_regex": @@ -373,7 +375,7 @@ class Filtering(Cog): additional_embeds = match additional_embeds_msg = "With the following embed(s):" - return message_content, additional_embeds, additional_embeds_msg + return Stats(message_content, additional_embeds, additional_embeds_msg) @staticmethod def _check_filter(msg: Message) -> bool: -- cgit v1.2.3 From 820173d4770361d437309169a8d6c7e48db89ec8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 20 Jul 2020 15:48:00 -0700 Subject: Filtering: use user mentions in mod logs --- bot/cogs/filtering.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 2118f03a2..07a603988 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -17,6 +17,7 @@ from bot.constants import ( Channels, Colours, Filter, Icons, URLs ) +from bot.utils.messages import format_user from bot.utils.redis_cache import RedisCache from bot.utils.scheduling import Scheduler @@ -190,8 +191,8 @@ class Filtering(Cog): log.info(f"Sending bad nickname alert for '{member.display_name}' ({member.id}).") log_string = ( - f"**User:** {member.mention} (`{member.id}`)\n" - f"**Display Name:** {member.display_name}\n" + f"**User:** {format_user(member)}\n" + f"**Display Name:** {escape_markdown(member.display_name)}\n" f"**Bad Matches:** {', '.join(match.group() for match in matches)}" ) @@ -316,10 +317,8 @@ class Filtering(Cog): eval_msg = "using !eval" if is_eval else "" message = ( - f"The {filter_name} {filter_type} was triggered " - f"by **{msg.author}** " - f"(`{msg.author.id}`) {channel_str} {eval_msg}with [the " - f"following message]({msg.jump_url}):\n\n" + f"The {filter_name} {filter_type} was triggered by {format_user(msg.author)} " + f"{channel_str} {eval_msg}with [the following message]({msg.jump_url}):\n\n" f"{stats.message_content}" ) -- cgit v1.2.3 From fbf1094b32ec489f75d186017a9c680b2bd6a263 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 20 Jul 2020 16:03:11 -0700 Subject: ModLog: use user mentions --- bot/cogs/moderation/modlog.py | 34 ++++++++++++---------------------- 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 0a63f57b8..724651ecd 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -12,10 +12,10 @@ from deepdiff import DeepDiff from discord import Colour from discord.abc import GuildChannel from discord.ext.commands import Cog, Context -from discord.utils import escape_markdown from bot.bot import Bot from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs +from bot.utils.messages import format_user from bot.utils.time import humanize_delta log = logging.getLogger(__name__) @@ -392,7 +392,7 @@ class ModLog(Cog, name="ModLog"): await self.send_log_message( Icons.user_ban, Colours.soft_red, - "User banned", f"{member} (`{member.id}`)", + "User banned", format_user(member), thumbnail=member.avatar_url_as(static_format="png"), channel_id=Channels.user_log ) @@ -403,8 +403,7 @@ class ModLog(Cog, name="ModLog"): if member.guild.id != GuildConstant.id: return - member_str = escape_markdown(str(member)) - message = f"{member_str} (`{member.id}`)" + message = format_user(member) now = datetime.utcnow() difference = abs(relativedelta(now, member.created_at)) @@ -430,10 +429,9 @@ class ModLog(Cog, name="ModLog"): self._ignored[Event.member_remove].remove(member.id) return - member_str = escape_markdown(str(member)) await self.send_log_message( Icons.sign_out, Colours.soft_red, - "User left", f"{member_str} (`{member.id}`)", + "User left", format_user(member), thumbnail=member.avatar_url_as(static_format="png"), channel_id=Channels.user_log ) @@ -448,10 +446,9 @@ class ModLog(Cog, name="ModLog"): self._ignored[Event.member_unban].remove(member.id) return - member_str = escape_markdown(str(member)) await self.send_log_message( Icons.user_unban, Colour.blurple(), - "User unbanned", f"{member_str} (`{member.id}`)", + "User unbanned", format_user(member), thumbnail=member.avatar_url_as(static_format="png"), channel_id=Channels.mod_log ) @@ -511,8 +508,7 @@ class ModLog(Cog, name="ModLog"): for item in sorted(changes): message += f"{Emojis.bullet} {item}\n" - member_str = escape_markdown(str(after)) - message = f"**{member_str}** (`{after.id}`)\n{message}" + message = f"{format_user(after)}\n{message}" await self.send_log_message( icon_url=Icons.user_update, @@ -545,17 +541,16 @@ class ModLog(Cog, name="ModLog"): if author.bot: return - author_str = escape_markdown(str(author)) if channel.category: response = ( - f"**Author:** {author_str} (`{author.id}`)\n" + f"**Author:** {format_user(author)}\n" f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" "\n" ) else: response = ( - f"**Author:** {author_str} (`{author.id}`)\n" + f"**Author:** {format_user(author)}\n" f"**Channel:** #{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" "\n" @@ -641,9 +636,6 @@ class ModLog(Cog, name="ModLog"): if msg_before.content == msg_after.content: return - author = msg_before.author - author_str = escape_markdown(str(author)) - channel = msg_before.channel channel_name = f"{channel.category}/#{channel.name}" if channel.category else f"#{channel.name}" @@ -675,7 +667,7 @@ class ModLog(Cog, name="ModLog"): content_after.append(sub) response = ( - f"**Author:** {author_str} (`{author.id}`)\n" + f"**Author:** {format_user(msg_before.author)}\n" f"**Channel:** {channel_name} (`{channel.id}`)\n" f"**Message ID:** `{msg_before.id}`\n" "\n" @@ -727,12 +719,11 @@ class ModLog(Cog, name="ModLog"): self._cached_edits.remove(event.message_id) return - author = message.author channel = message.channel channel_name = f"{channel.category}/#{channel.name}" if channel.category else f"#{channel.name}" before_response = ( - f"**Author:** {author} (`{author.id}`)\n" + f"**Author:** {format_user(message.author)}\n" f"**Channel:** {channel_name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" "\n" @@ -740,7 +731,7 @@ class ModLog(Cog, name="ModLog"): ) after_response = ( - f"**Author:** {author} (`{author.id}`)\n" + f"**Author:** {format_user(message.author)}\n" f"**Channel:** {channel_name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" "\n" @@ -818,9 +809,8 @@ class ModLog(Cog, name="ModLog"): if not changes: return - member_str = escape_markdown(str(member)) message = "\n".join(f"{Emojis.bullet} {item}" for item in sorted(changes)) - message = f"**{member_str}** (`{member.id}`)\n{message}" + message = f"{format_user(member)}\n{message}" await self.send_log_message( icon_url=icon, -- cgit v1.2.3 From 480dbef17e0ef6dc58c06dfd69a787725df001be Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 21 Jul 2020 09:55:12 -0700 Subject: Use user mentions in infraction search results Using our expanded API is more efficient than making a request to the Discord API for potentially every user in the search results. The data may not be up to date, but that's an acceptable compromise. --- bot/cogs/moderation/management.py | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index e0a86ee59..b4c69acc2 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -6,6 +6,7 @@ from datetime import datetime import discord from discord.ext import commands from discord.ext.commands import Context +from discord.utils import escape_markdown from bot import constants from bot.bot import Bot @@ -187,7 +188,7 @@ class ModManagement(commands.Cog): async def search_user(self, ctx: Context, user: t.Union[discord.User, proxy_user]) -> None: """Search for infractions by member.""" infraction_list = await self.bot.api_client.get( - 'bot/infractions', + 'bot/infractions/expanded', params={'user__id': str(user.id)} ) embed = discord.Embed( @@ -200,7 +201,7 @@ class ModManagement(commands.Cog): async def search_reason(self, ctx: Context, reason: str) -> None: """Search for infractions by their reason. Use Re2 for matching.""" infraction_list = await self.bot.api_client.get( - 'bot/infractions', + 'bot/infractions/expanded', params={'search': reason} ) embed = discord.Embed( @@ -237,37 +238,43 @@ class ModManagement(commands.Cog): max_size=1000 ) - def infraction_to_string(self, infraction: utils.Infraction) -> str: + def infraction_to_string(self, infraction: t.Dict[str, t.Any]) -> str: """Convert the infraction object to a string representation.""" - actor_id = infraction["actor"] - guild = self.bot.get_guild(constants.Guild.id) - actor = guild.get_member(actor_id) active = infraction["active"] - user_id = infraction["user"] - hidden = infraction["hidden"] + user = infraction["user"] + expires_at = infraction["expires_at"] created = time.format_infraction(infraction["inserted_at"]) + # Format the user string. + if user_obj := self.bot.get_user(user["id"]): + # The user is in the cache. + user_str = messages.format_user(user_obj) + else: + # Use the user data retrieved from the DB. + name = escape_markdown(user['name']) + user_str = f"<@{user['id']}> ({name}#{user['discriminator']})" + if active: - remaining = time.until_expiration(infraction["expires_at"]) or "Expired" + remaining = time.until_expiration(expires_at) or "Expired" else: remaining = "Inactive" - if infraction["expires_at"] is None: + if expires_at is None: expires = "*Permanent*" else: date_from = datetime.strptime(created, time.INFRACTION_FORMAT) - expires = time.format_infraction_with_duration(infraction["expires_at"], date_from) + expires = time.format_infraction_with_duration(expires_at, date_from) lines = textwrap.dedent(f""" {"**===============**" if active else "==============="} Status: {"__**Active**__" if active else "Inactive"} - User: {self.bot.get_user(user_id)} (`{user_id}`) + User: {user_str} Type: **{infraction["type"]}** - Shadow: {hidden} + Shadow: {infraction["hidden"]} Created: {created} Expires: {expires} Remaining: {remaining} - Actor: {actor.mention if actor else actor_id} + Actor: <@{infraction["actor"]["id"]}> ID: `{infraction["id"]}` Reason: {infraction["reason"] or "*None*"} {"**===============**" if active else "==============="} -- cgit v1.2.3 From abe46b7ee2496c5676e7c5b8c809358d2fab90a5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 21 Jul 2020 10:37:26 -0700 Subject: Fix test for token remover log message --- tests/bot/cogs/test_token_remover.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 3349caa73..1c7267f56 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -240,8 +240,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(return_value, log_message.format.return_value) log_message.format.assert_called_once_with( - author=self.msg.author, - author_id=self.msg.author.id, + author=f"{self.msg.author.mention} ({self.msg.author})", channel=self.msg.channel.mention, user_id=token.user_id, timestamp=token.timestamp, -- cgit v1.2.3 From bb774296cf613c66dc8d2863547c1147e8ad6520 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 21 Jul 2020 13:28:07 -0700 Subject: Charinfo: use send_denial helper --- bot/cogs/utils.py | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 697bf60ce..60e160ed0 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -12,6 +12,7 @@ from discord.ext.commands import BadArgument, Cog, Context, command from bot.bot import Bot from bot.constants import Channels, MODERATION_ROLES, STAFF_ROLES from bot.decorators import in_whitelist, with_role +from bot.utils import messages log = logging.getLogger(__name__) @@ -120,22 +121,15 @@ class Utils(Cog): """Shows you information on up to 25 unicode characters.""" match = re.match(r"<(a?):(\w+):(\d+)>", characters) if match: - embed = Embed( - title="Non-Character Detected", - description=( - "Only unicode characters can be processed, but a custom Discord emoji " - "was found. Please remove it and try again." - ) + return await messages.send_denial( + ctx, + "**Non-Character Detected**\n" + "Only unicode characters can be processed, but a custom Discord emoji " + "was found. Please remove it and try again." ) - embed.colour = Colour.red() - await ctx.send(embed=embed) - return if len(characters) > 25: - embed = Embed(title=f"Too many characters ({len(characters)}/25)") - embed.colour = Colour.red() - await ctx.send(embed=embed) - return + return await messages.send_denial(ctx, f"Too many characters ({len(characters)}/25)") def get_info(char: str) -> Tuple[str, str]: digit = f"{ord(char):x}" -- cgit v1.2.3 From 6c367269032b85fd60094228178209760aa8d282 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 21 Jul 2020 13:48:21 -0700 Subject: Charinfo: paginate the results Pagination ensures the results will never go over the char limit for an embed. Fixes #897 Fixes BOT-3D --- bot/cogs/utils.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 60e160ed0..d70fb300d 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -12,6 +12,7 @@ from discord.ext.commands import BadArgument, Cog, Context, command from bot.bot import Bot from bot.constants import Channels, MODERATION_ROLES, STAFF_ROLES from bot.decorators import in_whitelist, with_role +from bot.pagination import LinePaginator from bot.utils import messages log = logging.getLogger(__name__) @@ -142,15 +143,14 @@ class Utils(Cog): info = f"`{u_code.ljust(10)}`: {name} - {utils.escape_markdown(char)}" return info, u_code - charlist, rawlist = zip(*(get_info(c) for c in characters)) - - embed = Embed(description="\n".join(charlist)) - embed.set_author(name="Character Info") + char_list, raw_list = zip(*(get_info(c) for c in characters)) + embed = Embed().set_author(name="Character Info") if len(characters) > 1: - embed.add_field(name='Raw', value=f"`{''.join(rawlist)}`", inline=False) + # Maximum length possible is 252 so no need to truncate. + embed.add_field(name='Raw', value=f"`{''.join(raw_list)}`", inline=False) - await ctx.send(embed=embed) + await LinePaginator.paginate(char_list, ctx, embed, max_size=2000, empty=False) @command() async def zen(self, ctx: Context, *, search_value: Union[int, str, None] = None) -> None: -- cgit v1.2.3 From 63c7827d9d9025c7505747904237b37eb46464df Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 22 Jul 2020 11:42:31 -0700 Subject: Jam Tests: fix utils patch stop needs to be called on the patcher, not the mock. Furthermore, using addCleanup is safer than tearDown because the latter may not be called if an exception is raised in setUp. --- tests/bot/cogs/test_jams.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index 2f2cb4695..28eb1ab53 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -16,11 +16,12 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): self.guild = MockGuild([self.admin_role]) self.ctx = MockContext(bot=self.bot, author=self.command_user, guild=self.guild) self.cog = CodeJams(self.bot) - self.utils_mock = patch("bot.cogs.jams.utils").start() - self.default_args = [self.cog, self.ctx, "foo"] - def tearDown(self): - self.utils_mock.stop() + utils_patcher = patch("bot.cogs.jams.utils") + self.utils_mock = utils_patcher.start() + self.addCleanup(utils_patcher.stop) + + self.default_args = [self.cog, self.ctx, "foo"] async def test_too_small_amount_of_team_members_passed(self): """Should `ctx.send` and exit early when too small amount of members.""" -- cgit v1.2.3 From f7e177357e7a47d9a43b492aac7703961af72c19 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 22 Jul 2020 11:43:58 -0700 Subject: Jam Tests: re-arrange tests to follow definition order in the cog --- tests/bot/cogs/test_jams.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index 28eb1ab53..e0018e006 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -48,6 +48,16 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): self.cog.create_channels.assert_not_awaited() self.cog.add_roles.assert_not_awaited() + async def test_result_sending(self): + """Should call `ctx.send` when everything goes right.""" + self.cog.create_channels = AsyncMock() + self.cog.add_roles = AsyncMock() + members = [MockMember() for _ in range(5)] + await self.cog.createteam(self.cog, self.ctx, "foo", members) + self.cog.create_channels.assert_awaited_once() + self.cog.add_roles.assert_awaited_once() + self.ctx.send.assert_awaited_once() + async def test_category_dont_exist(self): """Should create code jam category.""" self.utils_mock.get.return_value = None @@ -125,16 +135,6 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): for member in members: member.add_roles.assert_any_await(jam_role) - async def test_result_sending(self): - """Should call `ctx.send` when everything goes right.""" - self.cog.create_channels = AsyncMock() - self.cog.add_roles = AsyncMock() - members = [MockMember() for _ in range(5)] - await self.cog.createteam(self.cog, self.ctx, "foo", members) - self.cog.create_channels.assert_awaited_once() - self.cog.add_roles.assert_awaited_once() - self.ctx.send.assert_awaited_once() - class CodeJamSetup(unittest.TestCase): """Test for `setup` function of `CodeJam` cog.""" -- cgit v1.2.3 From b1d0f36356ecf4eee729bf276c8b0ed10653ad54 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 22 Jul 2020 11:47:49 -0700 Subject: Jam Tests: remove default_args attribute Kind of redundant since it's only used by two tests. --- tests/bot/cogs/test_jams.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index e0018e006..0fce2a67c 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -21,8 +21,6 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): self.utils_mock = utils_patcher.start() self.addCleanup(utils_patcher.stop) - self.default_args = [self.cog, self.ctx, "foo"] - async def test_too_small_amount_of_team_members_passed(self): """Should `ctx.send` and exit early when too small amount of members.""" for case in (1, 2): @@ -32,7 +30,8 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): self.ctx.reset_mock() self.utils_mock.reset_mock() - await self.cog.createteam(*self.default_args, (MockMember() for _ in range(case))) + members = (MockMember() for _ in range(case)) + await self.cog.createteam(self.cog, self.ctx, "foo", members) self.ctx.send.assert_awaited_once() self.cog.create_channels.assert_not_awaited() @@ -43,7 +42,7 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): self.cog.create_channels = AsyncMock() self.cog.add_roles = AsyncMock() member = MockMember() - await self.cog.createteam(*self.default_args, (member for _ in range(5))) + await self.cog.createteam(self.cog, self.ctx, "foo", (member for _ in range(5))) self.ctx.send.assert_awaited_once() self.cog.create_channels.assert_not_awaited() self.cog.add_roles.assert_not_awaited() -- cgit v1.2.3 From 44cd1d989d491d692d48324228ccc9593a545cd2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 22 Jul 2020 11:51:08 -0700 Subject: Jam Tests: space out lines for readability --- tests/bot/cogs/test_jams.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index 0fce2a67c..81fbcb798 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -41,8 +41,10 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): """Should `ctx.send` and exit early because duplicate members provided and total there is only 1 member.""" self.cog.create_channels = AsyncMock() self.cog.add_roles = AsyncMock() + member = MockMember() await self.cog.createteam(self.cog, self.ctx, "foo", (member for _ in range(5))) + self.ctx.send.assert_awaited_once() self.cog.create_channels.assert_not_awaited() self.cog.add_roles.assert_not_awaited() @@ -51,8 +53,10 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): """Should call `ctx.send` when everything goes right.""" self.cog.create_channels = AsyncMock() self.cog.add_roles = AsyncMock() + members = [MockMember() for _ in range(5)] await self.cog.createteam(self.cog, self.ctx, "foo", members) + self.cog.create_channels.assert_awaited_once() self.cog.add_roles.assert_awaited_once() self.ctx.send.assert_awaited_once() @@ -60,7 +64,9 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): async def test_category_dont_exist(self): """Should create code jam category.""" self.utils_mock.get.return_value = None + await self.cog.get_category(self.guild) + self.guild.create_category_channel.assert_awaited_once() category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"] -- cgit v1.2.3 From 1f0222129a3d9b01d97671360296d982629bd25d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 22 Jul 2020 15:50:30 -0700 Subject: Jams: create a new category if others are full --- bot/cogs/jams.py | 51 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py index a48dbc49a..b3102db2f 100644 --- a/bot/cogs/jams.py +++ b/bot/cogs/jams.py @@ -1,7 +1,7 @@ import logging import typing as t -from discord import CategoryChannel, Guild, Member, PermissionOverwrite, Role, utils +from discord import CategoryChannel, Guild, Member, PermissionOverwrite, Role from discord.ext import commands from more_itertools import unique_everseen @@ -11,6 +11,9 @@ from bot.decorators import with_role log = logging.getLogger(__name__) +MAX_CHANNELS = 50 +CATEGORY_NAME = "Code Jam" + class CodeJams(commands.Cog): """Manages the code-jam related parts of our server.""" @@ -50,30 +53,38 @@ class CodeJams(commands.Cog): f"**Team Members:** {' '.join(member.mention for member in members[1:])}" ) + async def get_category(self, guild: Guild) -> CategoryChannel: + """ + Return a code jam category. + + If all categories are full or none exist, create a new category. + """ + for category in guild.categories: + # Need 2 available spaces: one for the text channel and one for voice. + if category.name == CATEGORY_NAME and MAX_CHANNELS - len(category.channels) >= 2: + return category + + return await self.create_category(guild) + @staticmethod - async def get_category(guild: Guild) -> CategoryChannel: - """Create a Code Jam category if it doesn't exist and return it.""" - code_jam_category = utils.get(guild.categories, name="Code Jam") - - if code_jam_category is None: - log.info("Code Jam category not found, creating it.") - - category_overwrites = { - guild.default_role: PermissionOverwrite(read_messages=False), - guild.me: PermissionOverwrite(read_messages=True) - } - - code_jam_category = await guild.create_category_channel( - "Code Jam", - overwrites=category_overwrites, - reason="It's code jam time!" - ) + async def create_category(guild: Guild) -> CategoryChannel: + """Create a new code jam category and return it.""" + log.info("Creating a new code jam category.") - return code_jam_category + category_overwrites = { + guild.default_role: PermissionOverwrite(read_messages=False), + guild.me: PermissionOverwrite(read_messages=True) + } + + return await guild.create_category_channel( + CATEGORY_NAME, + overwrites=category_overwrites, + reason="It's code jam time!" + ) @staticmethod def get_overwrites(members: t.List[Member], guild: Guild) -> t.Dict[t.Union[Member, Role], PermissionOverwrite]: - """Get Code Jam team channels permission overwrites.""" + """Get code jam team channels permission overwrites.""" # First member is always the team leader team_channel_overwrites = { members[0]: PermissionOverwrite( -- cgit v1.2.3 From 12168766a153d9d1bd134ff64f74997eef8ff7b0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 22 Jul 2020 16:11:32 -0700 Subject: Jam tests: fix category test --- tests/bot/cogs/test_jams.py | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index 81fbcb798..54a096703 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -1,11 +1,22 @@ import unittest -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, create_autospec -from bot.cogs.jams import CodeJams, setup +from discord import CategoryChannel + +from bot.cogs import jams from bot.constants import Roles from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockTextChannel +def get_mock_category(channel_count: int, name: str) -> CategoryChannel: + """Return a mocked code jam category.""" + category = create_autospec(CategoryChannel, spec_set=True, instance=True) + category.name = name + category.channels = [MockTextChannel() for _ in range(channel_count)] + + return category + + class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): """Tests for `createteam` command.""" @@ -15,11 +26,7 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): self.command_user = MockMember([self.admin_role]) self.guild = MockGuild([self.admin_role]) self.ctx = MockContext(bot=self.bot, author=self.command_user, guild=self.guild) - self.cog = CodeJams(self.bot) - - utils_patcher = patch("bot.cogs.jams.utils") - self.utils_mock = utils_patcher.start() - self.addCleanup(utils_patcher.stop) + self.cog = jams.CodeJams(self.bot) async def test_too_small_amount_of_team_members_passed(self): """Should `ctx.send` and exit early when too small amount of members.""" @@ -29,7 +36,6 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): self.cog.add_roles = AsyncMock() self.ctx.reset_mock() - self.utils_mock.reset_mock() members = (MockMember() for _ in range(case)) await self.cog.createteam(self.cog, self.ctx, "foo", members) @@ -63,8 +69,6 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): async def test_category_dont_exist(self): """Should create code jam category.""" - self.utils_mock.get.return_value = None - await self.cog.get_category(self.guild) self.guild.create_category_channel.assert_awaited_once() @@ -75,8 +79,15 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): async def test_category_channel_exist(self): """Should not try to create category channel.""" - await self.cog.get_category(self.guild) - self.guild.create_category_channel.assert_not_awaited() + expected_category = get_mock_category(48, jams.CATEGORY_NAME) + self.guild.categories = [ + get_mock_category(48, "other"), + expected_category, + get_mock_category(6, jams.CATEGORY_NAME), + ] + + actual_category = await self.cog.get_category(self.guild) + self.assertEqual(expected_category, actual_category) async def test_channel_overwrites(self): """Should have correct permission overwrites for users and roles.""" @@ -103,7 +114,6 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): async def test_team_channels_creation(self): """Should create new voice and text channel for team.""" - self.utils_mock.get.return_value = "foo" members = [MockMember() for _ in range(5)] self.cog.get_overwrites = MagicMock() @@ -147,5 +157,5 @@ class CodeJamSetup(unittest.TestCase): def test_setup(self): """Should call `bot.add_cog`.""" bot = MockBot() - setup(bot) + jams.setup(bot) bot.add_cog.assert_called_once() -- cgit v1.2.3 From 92d3f88eb5c2348f3e4cb53a22a833bed61c6fb7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 22 Jul 2020 16:21:38 -0700 Subject: Jam tests: add subtests to non-existent category test The test has to account for not only the name not matching, but also a lack of available spaces for new channels. --- tests/bot/cogs/test_jams.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index 54a096703..e6b2ac588 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -67,15 +67,26 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): self.cog.add_roles.assert_awaited_once() self.ctx.send.assert_awaited_once() - async def test_category_dont_exist(self): - """Should create code jam category.""" - await self.cog.get_category(self.guild) + async def test_category_doesnt_exist(self): + """Should create a new code jam category.""" + subtests = ( + [], + [get_mock_category(jams.MAX_CHANNELS - 1, jams.CATEGORY_NAME)], + [get_mock_category(48, "other")], + ) + + for categories in subtests: + self.guild.reset_mock() + self.guild.categories = categories + + with self.subTest(categories=categories): + await self.cog.get_category(self.guild) - self.guild.create_category_channel.assert_awaited_once() - category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"] + self.guild.create_category_channel.assert_awaited_once() + category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"] - self.assertFalse(category_overwrites[self.guild.default_role].read_messages) - self.assertTrue(category_overwrites[self.guild.me].read_messages) + self.assertFalse(category_overwrites[self.guild.default_role].read_messages) + self.assertTrue(category_overwrites[self.guild.me].read_messages) async def test_category_channel_exist(self): """Should not try to create category channel.""" -- cgit v1.2.3 From ddba3f5fcfbda0f72baa3f15055c8a92e94c6d88 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 22 Jul 2020 16:27:02 -0700 Subject: Jam tests: assert equality of new category --- tests/bot/cogs/test_jams.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index e6b2ac588..a76a8a051 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -80,13 +80,14 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): self.guild.categories = categories with self.subTest(categories=categories): - await self.cog.get_category(self.guild) + actual_category = await self.cog.get_category(self.guild) self.guild.create_category_channel.assert_awaited_once() category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"] self.assertFalse(category_overwrites[self.guild.default_role].read_messages) self.assertTrue(category_overwrites[self.guild.me].read_messages) + self.assertEqual(self.guild.create_category_channel.return_value, actual_category) async def test_category_channel_exist(self): """Should not try to create category channel.""" -- cgit v1.2.3 From 8e3c05210f057ab76d135afbe12035847c9029f4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 22 Jul 2020 16:54:53 -0700 Subject: Jam tests: use the MAX_CHANNELS constant more It's clearer to write MAX_CHANNELS - 2 than a literal 48. --- tests/bot/cogs/test_jams.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py index a76a8a051..b4ad8535f 100644 --- a/tests/bot/cogs/test_jams.py +++ b/tests/bot/cogs/test_jams.py @@ -72,7 +72,7 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): subtests = ( [], [get_mock_category(jams.MAX_CHANNELS - 1, jams.CATEGORY_NAME)], - [get_mock_category(48, "other")], + [get_mock_category(jams.MAX_CHANNELS - 2, "other")], ) for categories in subtests: @@ -91,11 +91,11 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): async def test_category_channel_exist(self): """Should not try to create category channel.""" - expected_category = get_mock_category(48, jams.CATEGORY_NAME) + expected_category = get_mock_category(jams.MAX_CHANNELS - 2, jams.CATEGORY_NAME) self.guild.categories = [ - get_mock_category(48, "other"), + get_mock_category(jams.MAX_CHANNELS - 2, "other"), expected_category, - get_mock_category(6, jams.CATEGORY_NAME), + get_mock_category(0, jams.CATEGORY_NAME), ] actual_category = await self.cog.get_category(self.guild) -- cgit v1.2.3 From 14b00ad1fdc065f1f5412a875f31182d4ccfe7a2 Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 22 Jul 2020 23:30:53 -0700 Subject: Charinfo: use more descriptive field name Since the raw field is displayed on every page, but pages are incomplete, it may be unclear whether the field's value is for the current page or for all pages. Co-authored-by: Kieran Siek --- bot/cogs/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index d70fb300d..8171706d0 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -148,7 +148,7 @@ class Utils(Cog): if len(characters) > 1: # Maximum length possible is 252 so no need to truncate. - embed.add_field(name='Raw', value=f"`{''.join(raw_list)}`", inline=False) + embed.add_field(name='Full Raw Text', value=f"`{''.join(raw_list)}`", inline=False) await LinePaginator.paginate(char_list, ctx, embed, max_size=2000, empty=False) -- cgit v1.2.3 From c35842b30d7bf8c58251ce780c4fe75eaf23a69f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 22 Jul 2020 23:35:51 -0700 Subject: Charinfo: up char limit and reduce line limit Pagination means more characters can be supported without cluttering anything. It also means infinite lines, so there's no longer a need to squeeze out the most from a single page. Reducing the line limit leads to a smaller, tidier presentation. --- bot/cogs/utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 8171706d0..c0dc284e4 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -119,7 +119,7 @@ class Utils(Cog): @command() @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES) async def charinfo(self, ctx: Context, *, characters: str) -> None: - """Shows you information on up to 25 unicode characters.""" + """Shows you information on up to 50 unicode characters.""" match = re.match(r"<(a?):(\w+):(\d+)>", characters) if match: return await messages.send_denial( @@ -129,7 +129,7 @@ class Utils(Cog): "was found. Please remove it and try again." ) - if len(characters) > 25: + if len(characters) > 50: return await messages.send_denial(ctx, f"Too many characters ({len(characters)}/25)") def get_info(char: str) -> Tuple[str, str]: @@ -147,10 +147,10 @@ class Utils(Cog): embed = Embed().set_author(name="Character Info") if len(characters) > 1: - # Maximum length possible is 252 so no need to truncate. + # Maximum length possible is 502 out of 1024, so there's no need to truncate. embed.add_field(name='Full Raw Text', value=f"`{''.join(raw_list)}`", inline=False) - await LinePaginator.paginate(char_list, ctx, embed, max_size=2000, empty=False) + await LinePaginator.paginate(char_list, ctx, embed, max_lines=10, max_size=2000, empty=False) @command() async def zen(self, ctx: Context, *, search_value: Union[int, str, None] = None) -> None: -- cgit v1.2.3 From 53fe5d44f2a44a2102d00d29c73b4cde733f9f26 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Thu, 23 Jul 2020 14:39:01 +0800 Subject: Check that embed desc is not Empty before stripping. --- bot/cogs/help_channels.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 0c8cbb417..e0fd06654 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -428,8 +428,11 @@ class HelpChannels(commands.Cog): if not message or not message.embeds: return False - embed = message.embeds[0] - return message.author == self.bot.user and embed.description.strip() == description.strip() + bot_msg_desc = message.embeds[0].description + if bot_msg_desc is discord.Embed.Empty: + log.trace("Last message was a bot embed but it was empty.") + return False + return message.author == self.bot.user and bot_msg_desc.strip() == description.strip() @staticmethod def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: -- cgit v1.2.3 From e15ceb83ac0ac081325ee47b1f6dddc581229197 Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 22 Jul 2020 23:46:51 -0700 Subject: Charinfo: correct char limit used in error message Co-authored-by: Kieran Siek --- bot/cogs/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index c0dc284e4..017f3419e 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -130,7 +130,7 @@ class Utils(Cog): ) if len(characters) > 50: - return await messages.send_denial(ctx, f"Too many characters ({len(characters)}/25)") + return await messages.send_denial(ctx, f"Too many characters ({len(characters)}/50)") def get_info(char: str) -> Tuple[str, str]: digit = f"{ord(char):x}" -- cgit v1.2.3 From be14db91b1c70993773e67cfa663fef0cfa85666 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Thu, 23 Jul 2020 18:27:55 +0100 Subject: Disabled burst_shared filter temporarily --- config-default.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/config-default.yml b/config-default.yml index ad6149f6f..d0262be33 100644 --- a/config-default.yml +++ b/config-default.yml @@ -459,10 +459,6 @@ anti_spam: interval: 10 max: 7 - burst_shared: - interval: 10 - max: 20 - chars: interval: 5 max: 3_000 -- cgit v1.2.3 From 2723949b2fbc065bf870e7fee1275782071a180b Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Fri, 24 Jul 2020 09:47:49 +0200 Subject: Catch ResponseCodeError in the ValidAllowDenyListType converter. --- bot/converters.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 7e21c1542..55cc630f7 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -1,16 +1,16 @@ +import dateutil.parser +import dateutil.tz +import discord import logging import re import typing as t +from aiohttp import ClientConnectorError from datetime import datetime -from ssl import CertificateError - -import dateutil.parser -import dateutil.tz -import discord -from aiohttp import ClientConnectorError, ContentTypeError from dateutil.relativedelta import relativedelta from discord.ext.commands import BadArgument, Context, Converter, IDConverter, UserConverter +from ssl import CertificateError +from bot.api import ResponseCodeError from bot.constants import URLs from bot.utils.regex import INVITE_RE @@ -84,7 +84,7 @@ class ValidAllowDenyListType(Converter): """Checks whether the given string is a valid AllowDenyList type.""" try: valid_types = await ctx.bot.api_client.get('bot/allow_deny_lists/get_types') - except ContentTypeError: + except ResponseCodeError: raise BadArgument("Cannot validate list_type: Unable to fetch valid types from API.") valid_types = [enum for enum, classname in valid_types] -- cgit v1.2.3 From 6f066fe2e18495425f6fbe90518d25b00b4276b4 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Fri, 24 Jul 2020 10:07:20 +0200 Subject: Put valid_types_list inside the conditional. --- bot/converters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/converters.py b/bot/converters.py index 55cc630f7..41cd3f3e5 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -88,10 +88,10 @@ class ValidAllowDenyListType(Converter): raise BadArgument("Cannot validate list_type: Unable to fetch valid types from API.") valid_types = [enum for enum, classname in valid_types] - valid_types_list = '\n'.join([f"• {type_.lower()}" for type_ in valid_types]) list_type = list_type.upper() if list_type not in valid_types: + valid_types_list = '\n'.join([f"• {type_.lower()}" for type_ in valid_types]) raise BadArgument( f"You have provided an invalid list type!\n\n" f"Please provide one of the following: \n{valid_types_list}" -- cgit v1.2.3 From be52d33e5466f83fbf86d0bec3553f788bc08c27 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Fri, 24 Jul 2020 10:26:41 +0200 Subject: No need for all() in cog_check for AllowDenyLists. --- bot/cogs/allow_deny_lists.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/bot/cogs/allow_deny_lists.py b/bot/cogs/allow_deny_lists.py index 71a032ea5..e28e32bd6 100644 --- a/bot/cogs/allow_deny_lists.py +++ b/bot/cogs/allow_deny_lists.py @@ -210,10 +210,7 @@ class AllowDenyLists(Cog): def cog_check(self, ctx: Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" - checks = [ - with_role_check(ctx, *constants.MODERATION_ROLES), - ] - return all(checks) + return with_role_check(ctx, *constants.MODERATION_ROLES) def setup(bot: Bot) -> None: -- cgit v1.2.3 From 3aa33f3ec84a104ac13c0c60c21f4f149da5af78 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Fri, 24 Jul 2020 10:39:21 +0200 Subject: Add sanity to partner and verification check in filtering.py. --- bot/cogs/filtering.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index b5b1c823a..98a60f489 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -473,8 +473,8 @@ class Filtering(Cog): # Is this invite allowed? guild_partnered_or_verified = ( - 'PARTNERED' in guild.get("features") - or 'VERIFIED' in guild.get("features") + 'PARTNERED' in guild.get("features", []) + or 'VERIFIED' in guild.get("features", []) ) invite_not_allowed = ( guild_id in guild_invite_blacklist # Blacklisted guilds are never permitted. -- cgit v1.2.3 From 02e7672623dd1aea11a715e5187eaef7f8633d17 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Fri, 24 Jul 2020 10:42:09 +0200 Subject: More explicit dict indexing Addresses reviews from MarkKoz Co-authored-by: Mark --- bot/cogs/antimalware.py | 2 +- bot/cogs/filtering.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index 38ff1133d..5b56f937f 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -40,7 +40,7 @@ class AntiMalware(Cog): def _get_whitelisted_file_formats(self) -> list: """Get the file formats currently on the whitelist.""" - return [item.get('content') for item in self.bot.allow_deny_list_cache['file_format.True']] + return [item['content'] for item in self.bot.allow_deny_list_cache['file_format.True']] def _get_disallowed_extensions(self, message: Message) -> t.Iterable[str]: """Get an iterable containing all the disallowed extensions of attachments.""" diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 98a60f489..8897cbaf9 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -104,9 +104,9 @@ class Filtering(Cog): items = self.bot.allow_deny_list_cache.get(f"{list_type.upper()}.{allowed}", []) if compiled: - return [re.compile(fr'{item.get("content")}', flags=re.IGNORECASE) for item in items] + return [re.compile(fr'{item["content"]}', flags=re.IGNORECASE) for item in items] else: - return [item.get("content") for item in items] + return [item["content"] for item in items] @staticmethod def _expand_spoilers(text: str) -> str: -- cgit v1.2.3 From 6bab215b45b5ad2d40b68459a70e7731af2eb7a2 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Fri, 24 Jul 2020 17:42:42 +0800 Subject: Fix: Implicit string concatenation considered harmful Python joins two string adjacent string literals implicitly, which may cause unintended side effects when used with certain string methods. >>> 'A' ' '.join(['1', '2', '3']) '1A 2A 3' --- bot/cogs/information.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index d6090d481..8982196d1 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -116,10 +116,7 @@ class Information(Cog): parsed_roles.append(role) if failed_roles: - await ctx.send( - ":x: I could not convert the following role names to a role: \n- " - "\n- ".join(failed_roles) - ) + await ctx.send(f":x: Could not retrieve the following roles: {', '.join(failed_roles)}") for role in parsed_roles: h, s, v = colorsys.rgb_to_hsv(*role.colour.to_rgb()) -- cgit v1.2.3 From 3d5faa421756fadb42590db92e8fee64578390d4 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 27 Jul 2020 10:26:10 +0200 Subject: Rename AllowDenyList to FilterLists --- bot/__main__.py | 2 +- bot/bot.py | 14 +-- bot/cogs/allow_deny_lists.py | 218 ------------------------------------- bot/cogs/antimalware.py | 2 +- bot/cogs/filter_lists.py | 218 +++++++++++++++++++++++++++++++++++++ bot/cogs/filtering.py | 16 +-- bot/converters.py | 10 +- tests/bot/cogs/test_antimalware.py | 2 +- 8 files changed, 241 insertions(+), 241 deletions(-) delete mode 100644 bot/cogs/allow_deny_lists.py create mode 100644 bot/cogs/filter_lists.py diff --git a/bot/__main__.py b/bot/__main__.py index 932aa705c..c2271cd16 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -53,7 +53,7 @@ bot.load_extension("bot.cogs.verification") # Feature cogs bot.load_extension("bot.cogs.alias") -bot.load_extension("bot.cogs.allow_deny_lists") +bot.load_extension("bot.cogs.filter_lists") bot.load_extension("bot.cogs.defcon") bot.load_extension("bot.cogs.dm_relay") bot.load_extension("bot.cogs.duck_pond") diff --git a/bot/bot.py b/bot/bot.py index d834c151b..3dfb4e948 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -34,7 +34,7 @@ class Bot(commands.Bot): self.redis_ready = asyncio.Event() self.redis_closed = False self.api_client = api.APIClient(loop=self.loop) - self.allow_deny_list_cache = {} + self.filter_list_cache = {} self._connector = None self._resolver = None @@ -50,9 +50,9 @@ class Bot(commands.Bot): self.stats = AsyncStatsClient(self.loop, statsd_url, 8125, prefix="bot") - async def _cache_allow_deny_list_data(self) -> None: - """Cache all the data in the AllowDenyList on the site.""" - full_cache = await self.api_client.get('bot/allow_deny_lists') + async def _cache_filter_list_data(self) -> None: + """Cache all the data in the FilterList on the site.""" + full_cache = await self.api_client.get('bot/filter-lists') for item in full_cache: type_ = item.get("type") @@ -64,7 +64,7 @@ class Bot(commands.Bot): "created_at": item.get("created_at"), "updated_at": item.get("updated_at"), } - self.allow_deny_list_cache.setdefault(f"{type_}.{allowed}", []).append(metadata) + self.filter_list_cache.setdefault(f"{type_}.{allowed}", []).append(metadata) async def _create_redis_session(self) -> None: """ @@ -176,8 +176,8 @@ class Bot(commands.Bot): self.http_session = aiohttp.ClientSession(connector=self._connector) self.api_client.recreate(force=True, connector=self._connector) - # Build the AllowDenyList cache - self.loop.create_task(self._cache_allow_deny_list_data()) + # Build the FilterList cache + self.loop.create_task(self._cache_filter_list_data()) async def on_guild_available(self, guild: discord.Guild) -> None: """ diff --git a/bot/cogs/allow_deny_lists.py b/bot/cogs/allow_deny_lists.py deleted file mode 100644 index e28e32bd6..000000000 --- a/bot/cogs/allow_deny_lists.py +++ /dev/null @@ -1,218 +0,0 @@ -import logging -from typing import Optional - -from discord import Colour, Embed -from discord.ext.commands import BadArgument, Cog, Context, IDConverter, group - -from bot import constants -from bot.api import ResponseCodeError -from bot.bot import Bot -from bot.converters import ValidAllowDenyListType, ValidDiscordServerInvite -from bot.pagination import LinePaginator -from bot.utils.checks import with_role_check - -log = logging.getLogger(__name__) - - -class AllowDenyLists(Cog): - """Commands for blacklisting and whitelisting things.""" - - def __init__(self, bot: Bot) -> None: - self.bot = bot - - async def _add_data( - self, - ctx: Context, - allowed: bool, - list_type: ValidAllowDenyListType, - content: str, - comment: Optional[str] = None, - ) -> None: - """Add an item to an allow or denylist.""" - allow_type = "whitelist" if allowed else "blacklist" - - # If this is a server invite, we gotta validate it. - if list_type == "GUILD_INVITE": - log.trace(f"{content} is a guild invite, attempting to validate.") - validator = ValidDiscordServerInvite() - guild_data = await validator.convert(ctx, content) - - # If we make it this far without raising a BadArgument, the invite is - # valid. Let's convert the content to an ID. - log.trace(f"{content} validated as server invite. Converting to ID.") - content = guild_data.get("id") - - # Unless the user has specified another comment, let's - # use the server name as the comment so that the list - # of guild IDs will be more easily readable when we - # display it. - if not comment: - comment = guild_data.get("name") - - # Try to add the item to the database - log.trace(f"Trying to add the {content} item to the {list_type} {allow_type}") - payload = { - 'allowed': allowed, - 'type': list_type, - 'content': content, - 'comment': comment, - } - - try: - item = await self.bot.api_client.post( - "bot/allow_deny_lists", - json=payload - ) - except ResponseCodeError as e: - if e.status == 500: - await ctx.message.add_reaction("❌") - log.debug( - f"{ctx.author} tried to add data to a {allow_type}, but the API returned 500, " - "probably because the request violated the UniqueConstraint." - ) - raise BadArgument( - f"Unable to add the item to the {allow_type}. " - "The item probably already exists. Keep in mind that a " - "blacklist and a whitelist for the same item cannot co-exist, " - "and we do not permit any duplicates." - ) - raise - - # Insert the item into the cache - type_ = item.get("type") - allowed = item.get("allowed") - metadata = { - "content": item.get("content"), - "comment": item.get("comment"), - "id": item.get("id"), - "created_at": item.get("created_at"), - "updated_at": item.get("updated_at"), - } - self.bot.allow_deny_list_cache.setdefault(f"{type_}.{allowed}", []).append(metadata) - await ctx.message.add_reaction("✅") - - async def _delete_data(self, ctx: Context, allowed: bool, list_type: ValidAllowDenyListType, content: str) -> None: - """Remove an item from an allow or denylist.""" - item = None - allow_type = "whitelist" if allowed else "blacklist" - id_converter = IDConverter() - - # If this is a server invite, we need to convert it. - if list_type == "GUILD_INVITE" and not id_converter._get_id_match(content): - log.trace(f"{content} is a guild invite, attempting to validate.") - validator = ValidDiscordServerInvite() - guild_data = await validator.convert(ctx, content) - - # If we make it this far without raising a BadArgument, the invite is - # valid. Let's convert the content to an ID. - log.trace(f"{content} validated as server invite. Converting to ID.") - content = guild_data.get("id") - - # Find the content and delete it. - log.trace(f"Trying to delete the {content} item from the {list_type} {allow_type}") - for allow_list in self.bot.allow_deny_list_cache.get(f"{list_type}.{allowed}", []): - if content == allow_list.get("content"): - item = allow_list - break - - if item is not None: - await self.bot.api_client.delete( - f"bot/allow_deny_lists/{item.get('id')}" - ) - self.bot.allow_deny_list_cache[f"{list_type}.{allowed}"].remove(item) - await ctx.message.add_reaction("✅") - - async def _list_all_data(self, ctx: Context, allowed: bool, list_type: ValidAllowDenyListType) -> None: - """Paginate and display all items in an allow or denylist.""" - allow_type = "whitelist" if allowed else "blacklist" - result = self.bot.allow_deny_list_cache.get(f"{list_type}.{allowed}", []) - - # Build a list of lines we want to show in the paginator - lines = [] - for item in result: - line = f"• `{item.get('content')}`" - - if item.get("comment"): - line += f" - {item.get('comment')}" - - lines.append(line) - lines = sorted(lines) - - # Build the embed - list_type_plural = list_type.lower().replace("_", " ").title() + "s" - embed = Embed( - title=f"{allow_type.title()}ed {list_type_plural} ({len(result)} total)", - colour=Colour.blue() - ) - log.trace(f"Trying to list {len(result)} items from the {list_type.lower()} {allow_type}") - - if result: - await LinePaginator.paginate(lines, ctx, embed, max_lines=15, empty=False) - else: - embed.description = "Hmmm, seems like there's nothing here yet." - await ctx.send(embed=embed) - - @group(aliases=("allowlist", "allow", "al", "wl")) - async def whitelist(self, ctx: Context) -> None: - """Group for whitelisting commands.""" - if not ctx.invoked_subcommand: - await ctx.send_help(ctx.command) - - @group(aliases=("denylist", "deny", "bl", "dl")) - async def blacklist(self, ctx: Context) -> None: - """Group for blacklisting commands.""" - if not ctx.invoked_subcommand: - await ctx.send_help(ctx.command) - - @whitelist.command(name="add", aliases=("a", "set")) - async def allow_add( - self, - ctx: Context, - list_type: ValidAllowDenyListType, - content: str, - *, - comment: Optional[str] = None, - ) -> None: - """Add an item to the specified allowlist.""" - await self._add_data(ctx, True, list_type, content, comment) - - @blacklist.command(name="add", aliases=("a", "set")) - async def deny_add( - self, - ctx: Context, - list_type: ValidAllowDenyListType, - content: str, - *, - comment: Optional[str] = None, - ) -> None: - """Add an item to the specified denylist.""" - await self._add_data(ctx, False, list_type, content, comment) - - @whitelist.command(name="remove", aliases=("delete", "rm",)) - async def allow_delete(self, ctx: Context, list_type: ValidAllowDenyListType, content: str) -> None: - """Remove an item from the specified allowlist.""" - await self._delete_data(ctx, True, list_type, content) - - @blacklist.command(name="remove", aliases=("delete", "rm",)) - async def deny_delete(self, ctx: Context, list_type: ValidAllowDenyListType, content: str) -> None: - """Remove an item from the specified denylist.""" - await self._delete_data(ctx, False, list_type, content) - - @whitelist.command(name="get", aliases=("list", "ls", "fetch", "show")) - async def allow_get(self, ctx: Context, list_type: ValidAllowDenyListType) -> None: - """Get the contents of a specified allowlist.""" - await self._list_all_data(ctx, True, list_type) - - @blacklist.command(name="get", aliases=("list", "ls", "fetch", "show")) - async def deny_get(self, ctx: Context, list_type: ValidAllowDenyListType) -> None: - """Get the contents of a specified denylist.""" - await self._list_all_data(ctx, False, list_type) - - def cog_check(self, ctx: Context) -> bool: - """Only allow moderators to invoke the commands in this cog.""" - return with_role_check(ctx, *constants.MODERATION_ROLES) - - -def setup(bot: Bot) -> None: - """Load the AllowDenyLists cog.""" - bot.add_cog(AllowDenyLists(bot)) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index 5b56f937f..9a100b3fc 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -40,7 +40,7 @@ class AntiMalware(Cog): def _get_whitelisted_file_formats(self) -> list: """Get the file formats currently on the whitelist.""" - return [item['content'] for item in self.bot.allow_deny_list_cache['file_format.True']] + return [item['content'] for item in self.bot.filter_list_cache['file_format.True']] def _get_disallowed_extensions(self, message: Message) -> t.Iterable[str]: """Get an iterable containing all the disallowed extensions of attachments.""" diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py new file mode 100644 index 000000000..d1db9830e --- /dev/null +++ b/bot/cogs/filter_lists.py @@ -0,0 +1,218 @@ +import logging +from typing import Optional + +from discord import Colour, Embed +from discord.ext.commands import BadArgument, Cog, Context, IDConverter, group + +from bot import constants +from bot.api import ResponseCodeError +from bot.bot import Bot +from bot.converters import ValidDiscordServerInvite, ValidFilterListType +from bot.pagination import LinePaginator +from bot.utils.checks import with_role_check + +log = logging.getLogger(__name__) + + +class FilterLists(Cog): + """Commands for blacklisting and whitelisting things.""" + + def __init__(self, bot: Bot) -> None: + self.bot = bot + + async def _add_data( + self, + ctx: Context, + allowed: bool, + list_type: ValidFilterListType, + content: str, + comment: Optional[str] = None, + ) -> None: + """Add an item to a filterlist.""" + allow_type = "whitelist" if allowed else "blacklist" + + # If this is a server invite, we gotta validate it. + if list_type == "GUILD_INVITE": + log.trace(f"{content} is a guild invite, attempting to validate.") + validator = ValidDiscordServerInvite() + guild_data = await validator.convert(ctx, content) + + # If we make it this far without raising a BadArgument, the invite is + # valid. Let's convert the content to an ID. + log.trace(f"{content} validated as server invite. Converting to ID.") + content = guild_data.get("id") + + # Unless the user has specified another comment, let's + # use the server name as the comment so that the list + # of guild IDs will be more easily readable when we + # display it. + if not comment: + comment = guild_data.get("name") + + # Try to add the item to the database + log.trace(f"Trying to add the {content} item to the {list_type} {allow_type}") + payload = { + 'allowed': allowed, + 'type': list_type, + 'content': content, + 'comment': comment, + } + + try: + item = await self.bot.api_client.post( + "bot/filter-lists", + json=payload + ) + except ResponseCodeError as e: + if e.status == 500: + await ctx.message.add_reaction("❌") + log.debug( + f"{ctx.author} tried to add data to a {allow_type}, but the API returned 500, " + "probably because the request violated the UniqueConstraint." + ) + raise BadArgument( + f"Unable to add the item to the {allow_type}. " + "The item probably already exists. Keep in mind that a " + "blacklist and a whitelist for the same item cannot co-exist, " + "and we do not permit any duplicates." + ) + raise + + # Insert the item into the cache + type_ = item.get("type") + allowed = item.get("allowed") + metadata = { + "content": item.get("content"), + "comment": item.get("comment"), + "id": item.get("id"), + "created_at": item.get("created_at"), + "updated_at": item.get("updated_at"), + } + self.bot.filter_list_cache.setdefault(f"{type_}.{allowed}", []).append(metadata) + await ctx.message.add_reaction("✅") + + async def _delete_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType, content: str) -> None: + """Remove an item from a filterlist.""" + item = None + allow_type = "whitelist" if allowed else "blacklist" + id_converter = IDConverter() + + # If this is a server invite, we need to convert it. + if list_type == "GUILD_INVITE" and not id_converter._get_id_match(content): + log.trace(f"{content} is a guild invite, attempting to validate.") + validator = ValidDiscordServerInvite() + guild_data = await validator.convert(ctx, content) + + # If we make it this far without raising a BadArgument, the invite is + # valid. Let's convert the content to an ID. + log.trace(f"{content} validated as server invite. Converting to ID.") + content = guild_data.get("id") + + # Find the content and delete it. + log.trace(f"Trying to delete the {content} item from the {list_type} {allow_type}") + for allow_list in self.bot.filter_list_cache.get(f"{list_type}.{allowed}", []): + if content == allow_list.get("content"): + item = allow_list + break + + if item is not None: + await self.bot.api_client.delete( + f"bot/filter-lists/{item.get('id')}" + ) + self.bot.filter_list_cache[f"{list_type}.{allowed}"].remove(item) + await ctx.message.add_reaction("✅") + + async def _list_all_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType) -> None: + """Paginate and display all items in a filterlist.""" + allow_type = "whitelist" if allowed else "blacklist" + result = self.bot.filter_list_cache.get(f"{list_type}.{allowed}", []) + + # Build a list of lines we want to show in the paginator + lines = [] + for item in result: + line = f"• `{item.get('content')}`" + + if item.get("comment"): + line += f" - {item.get('comment')}" + + lines.append(line) + lines = sorted(lines) + + # Build the embed + list_type_plural = list_type.lower().replace("_", " ").title() + "s" + embed = Embed( + title=f"{allow_type.title()}ed {list_type_plural} ({len(result)} total)", + colour=Colour.blue() + ) + log.trace(f"Trying to list {len(result)} items from the {list_type.lower()} {allow_type}") + + if result: + await LinePaginator.paginate(lines, ctx, embed, max_lines=15, empty=False) + else: + embed.description = "Hmmm, seems like there's nothing here yet." + await ctx.send(embed=embed) + + @group(aliases=("allowlist", "allow", "al", "wl")) + async def whitelist(self, ctx: Context) -> None: + """Group for whitelisting commands.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @group(aliases=("denylist", "deny", "bl", "dl")) + async def blacklist(self, ctx: Context) -> None: + """Group for blacklisting commands.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @whitelist.command(name="add", aliases=("a", "set")) + async def allow_add( + self, + ctx: Context, + list_type: ValidFilterListType, + content: str, + *, + comment: Optional[str] = None, + ) -> None: + """Add an item to the specified allowlist.""" + await self._add_data(ctx, True, list_type, content, comment) + + @blacklist.command(name="add", aliases=("a", "set")) + async def deny_add( + self, + ctx: Context, + list_type: ValidFilterListType, + content: str, + *, + comment: Optional[str] = None, + ) -> None: + """Add an item to the specified denylist.""" + await self._add_data(ctx, False, list_type, content, comment) + + @whitelist.command(name="remove", aliases=("delete", "rm",)) + async def allow_delete(self, ctx: Context, list_type: ValidFilterListType, content: str) -> None: + """Remove an item from the specified allowlist.""" + await self._delete_data(ctx, True, list_type, content) + + @blacklist.command(name="remove", aliases=("delete", "rm",)) + async def deny_delete(self, ctx: Context, list_type: ValidFilterListType, content: str) -> None: + """Remove an item from the specified denylist.""" + await self._delete_data(ctx, False, list_type, content) + + @whitelist.command(name="get", aliases=("list", "ls", "fetch", "show")) + async def allow_get(self, ctx: Context, list_type: ValidFilterListType) -> None: + """Get the contents of a specified allowlist.""" + await self._list_all_data(ctx, True, list_type) + + @blacklist.command(name="get", aliases=("list", "ls", "fetch", "show")) + async def deny_get(self, ctx: Context, list_type: ValidFilterListType) -> None: + """Get the contents of a specified denylist.""" + await self._list_all_data(ctx, False, list_type) + + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + return with_role_check(ctx, *constants.MODERATION_ROLES) + + +def setup(bot: Bot) -> None: + """Load the FilterLists cog.""" + bot.add_cog(FilterLists(bot)) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 8897cbaf9..652af5ff5 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -99,9 +99,9 @@ class Filtering(Cog): self.bot.loop.create_task(self.reschedule_offensive_msg_deletion()) - def _get_allowlist_items(self, list_type: str, *, allowed: bool, compiled: Optional[bool] = False) -> list: - """Fetch items from the allow_deny_list_cache.""" - items = self.bot.allow_deny_list_cache.get(f"{list_type.upper()}.{allowed}", []) + def _get_filterlist_items(self, list_type: str, *, allowed: bool, compiled: Optional[bool] = False) -> list: + """Fetch items from the filter_list_cache.""" + items = self.bot.filter_list_cache.get(f"{list_type.upper()}.{allowed}", []) if compiled: return [re.compile(fr'{item["content"]}', flags=re.IGNORECASE) for item in items] @@ -143,7 +143,7 @@ class Filtering(Cog): def get_name_matches(self, name: str) -> List[re.Match]: """Check bad words from passed string (name). Return list of matches.""" matches = [] - watchlist_patterns = self._get_allowlist_items('word_watchlist', allowed=False, compiled=True) + watchlist_patterns = self._get_filterlist_items('word_watchlist', allowed=False, compiled=True) for pattern in watchlist_patterns: if match := pattern.search(name): matches.append(match) @@ -408,7 +408,7 @@ class Filtering(Cog): if URL_RE.search(text): return False - watchlist_patterns = self._get_allowlist_items('word_watchlist', allowed=False, compiled=True) + watchlist_patterns = self._get_filterlist_items('word_watchlist', allowed=False, compiled=True) for pattern in watchlist_patterns: match = pattern.search(text) if match: @@ -420,7 +420,7 @@ class Filtering(Cog): return False text = text.lower() - domain_blacklist = self._get_allowlist_items("domain_name", allowed=False) + domain_blacklist = self._get_filterlist_items("domain_name", allowed=False) for url in domain_blacklist: if url.lower() in text: @@ -468,8 +468,8 @@ class Filtering(Cog): return True guild_id = guild.get("id") - guild_invite_whitelist = self._get_allowlist_items("guild_invite", allowed=True) - guild_invite_blacklist = self._get_allowlist_items("guild_invite", allowed=False) + guild_invite_whitelist = self._get_filterlist_items("guild_invite", allowed=True) + guild_invite_blacklist = self._get_filterlist_items("guild_invite", allowed=False) # Is this invite allowed? guild_partnered_or_verified = ( diff --git a/bot/converters.py b/bot/converters.py index 41cd3f3e5..158bf1a16 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -72,18 +72,18 @@ class ValidDiscordServerInvite(Converter): raise BadArgument("This does not appear to be a valid Discord server invite.") -class ValidAllowDenyListType(Converter): +class ValidFilterListType(Converter): """ - A converter that checks whether the given string is a valid AllowDenyList type. + A converter that checks whether the given string is a valid FilterList type. - Raises `BadArgument` if the argument is not a valid AllowDenyList type, and simply + Raises `BadArgument` if the argument is not a valid FilterList type, and simply passes through the given argument otherwise. """ async def convert(self, ctx: Context, list_type: str) -> str: - """Checks whether the given string is a valid AllowDenyList type.""" + """Checks whether the given string is a valid FilterList type.""" try: - valid_types = await ctx.bot.api_client.get('bot/allow_deny_lists/get_types') + valid_types = await ctx.bot.api_client.get('bot/filter-lists/get-types') except ResponseCodeError: raise BadArgument("Cannot validate list_type: Unable to fetch valid types from API.") diff --git a/tests/bot/cogs/test_antimalware.py b/tests/bot/cogs/test_antimalware.py index 1e010d2ce..664fa8f19 100644 --- a/tests/bot/cogs/test_antimalware.py +++ b/tests/bot/cogs/test_antimalware.py @@ -14,7 +14,7 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): def setUp(self): """Sets up fresh objects for each test.""" self.bot = MockBot() - self.bot.allow_deny_list_cache = { + self.bot.filter_list_cache = { "file_format.True": [ {"content": ".first"}, {"content": ".second"}, -- cgit v1.2.3 From ba00d4f1525340141b0f6c85fbfb32793f5bdfdd Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 27 Jul 2020 10:26:49 +0200 Subject: Bump flake8 version to 3.8 This is necessary to support walrus operators. --- Pipfile | 2 +- Pipfile.lock | 362 +++++++++++++++++++++++++++++++---------------------------- 2 files changed, 191 insertions(+), 173 deletions(-) diff --git a/Pipfile b/Pipfile index 2d6b45aa9..4db8a238b 100644 --- a/Pipfile +++ b/Pipfile @@ -28,7 +28,7 @@ statsd = "~=3.3" [dev-packages] coverage = "~=5.0" -flake8 = "~=3.7" +flake8 = "~=3.8" flake8-annotations = "~=2.0" flake8-bugbear = "~=20.1" flake8-docstrings = "~=1.4" diff --git a/Pipfile.lock b/Pipfile.lock index 4b9d092d4..c8cd96d3d 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "8a53baefbbd2a0f3fbaf831f028b23d257a5e28b5efa1260661d74604f4113b8" + "sha256": "eab4852974d26bd2c10362540c3e01d34af62446cb4e1915ec9a0bf2bddf4d94" }, "pipfile-spec": 6, "requires": { @@ -115,36 +115,36 @@ }, "cffi": { "hashes": [ - "sha256:001bf3242a1bb04d985d63e138230802c6c8d4db3668fb545fb5005ddf5bb5ff", - "sha256:00789914be39dffba161cfc5be31b55775de5ba2235fe49aa28c148236c4e06b", - "sha256:028a579fc9aed3af38f4892bdcc7390508adabc30c6af4a6e4f611b0c680e6ac", - "sha256:14491a910663bf9f13ddf2bc8f60562d6bc5315c1f09c704937ef17293fb85b0", - "sha256:1cae98a7054b5c9391eb3249b86e0e99ab1e02bb0cc0575da191aedadbdf4384", - "sha256:2089ed025da3919d2e75a4d963d008330c96751127dd6f73c8dc0c65041b4c26", - "sha256:2d384f4a127a15ba701207f7639d94106693b6cd64173d6c8988e2c25f3ac2b6", - "sha256:337d448e5a725bba2d8293c48d9353fc68d0e9e4088d62a9571def317797522b", - "sha256:399aed636c7d3749bbed55bc907c3288cb43c65c4389964ad5ff849b6370603e", - "sha256:3b911c2dbd4f423b4c4fcca138cadde747abdb20d196c4a48708b8a2d32b16dd", - "sha256:3d311bcc4a41408cf5854f06ef2c5cab88f9fded37a3b95936c9879c1640d4c2", - "sha256:62ae9af2d069ea2698bf536dcfe1e4eed9090211dbaafeeedf5cb6c41b352f66", - "sha256:66e41db66b47d0d8672d8ed2708ba91b2f2524ece3dee48b5dfb36be8c2f21dc", - "sha256:675686925a9fb403edba0114db74e741d8181683dcf216be697d208857e04ca8", - "sha256:7e63cbcf2429a8dbfe48dcc2322d5f2220b77b2e17b7ba023d6166d84655da55", - "sha256:8a6c688fefb4e1cd56feb6c511984a6c4f7ec7d2a1ff31a10254f3c817054ae4", - "sha256:8c0ffc886aea5df6a1762d0019e9cb05f825d0eec1f520c51be9d198701daee5", - "sha256:95cd16d3dee553f882540c1ffe331d085c9e629499ceadfbda4d4fde635f4b7d", - "sha256:99f748a7e71ff382613b4e1acc0ac83bf7ad167fb3802e35e90d9763daba4d78", - "sha256:b8c78301cefcf5fd914aad35d3c04c2b21ce8629b5e4f4e45ae6812e461910fa", - "sha256:c420917b188a5582a56d8b93bdd8e0f6eca08c84ff623a4c16e809152cd35793", - "sha256:c43866529f2f06fe0edc6246eb4faa34f03fe88b64a0a9a942561c8e22f4b71f", - "sha256:cab50b8c2250b46fe738c77dbd25ce017d5e6fb35d3407606e7a4180656a5a6a", - "sha256:cef128cb4d5e0b3493f058f10ce32365972c554572ff821e175dbc6f8ff6924f", - "sha256:cf16e3cf6c0a5fdd9bc10c21687e19d29ad1fe863372b5543deaec1039581a30", - "sha256:e56c744aa6ff427a607763346e4170629caf7e48ead6921745986db3692f987f", - "sha256:e577934fc5f8779c554639376beeaa5657d54349096ef24abe8c74c5d9c117c3", - "sha256:f2b0fa0c01d8a0c7483afd9f31d7ecf2d71760ca24499c8697aeb5ca37dc090c" - ], - "version": "==1.14.0" + "sha256:267adcf6e68d77ba154334a3e4fc921b8e63cbb38ca00d33d40655d4228502bc", + "sha256:26f33e8f6a70c255767e3c3f957ccafc7f1f706b966e110b855bfe944511f1f9", + "sha256:3cd2c044517f38d1b577f05927fb9729d3396f1d44d0c659a445599e79519792", + "sha256:4a03416915b82b81af5502459a8a9dd62a3c299b295dcdf470877cb948d655f2", + "sha256:4ce1e995aeecf7cc32380bc11598bfdfa017d592259d5da00fc7ded11e61d022", + "sha256:4f53e4128c81ca3212ff4cf097c797ab44646a40b42ec02a891155cd7a2ba4d8", + "sha256:4fa72a52a906425416f41738728268072d5acfd48cbe7796af07a923236bcf96", + "sha256:66dd45eb9530e3dde8f7c009f84568bc7cac489b93d04ac86e3111fb46e470c2", + "sha256:6923d077d9ae9e8bacbdb1c07ae78405a9306c8fd1af13bfa06ca891095eb995", + "sha256:833401b15de1bb92791d7b6fb353d4af60dc688eaa521bd97203dcd2d124a7c1", + "sha256:8416ed88ddc057bab0526d4e4e9f3660f614ac2394b5e019a628cdfff3733849", + "sha256:892daa86384994fdf4856cb43c93f40cbe80f7f95bb5da94971b39c7f54b3a9c", + "sha256:98be759efdb5e5fa161e46d404f4e0ce388e72fbf7d9baf010aff16689e22abe", + "sha256:a6d28e7f14ecf3b2ad67c4f106841218c8ab12a0683b1528534a6c87d2307af3", + "sha256:b1d6ebc891607e71fd9da71688fcf332a6630b7f5b7f5549e6e631821c0e5d90", + "sha256:b2a2b0d276a136146e012154baefaea2758ef1f56ae9f4e01c612b0831e0bd2f", + "sha256:b87dfa9f10a470eee7f24234a37d1d5f51e5f5fa9eeffda7c282e2b8f5162eb1", + "sha256:bac0d6f7728a9cc3c1e06d4fcbac12aaa70e9379b3025b27ec1226f0e2d404cf", + "sha256:c991112622baee0ae4d55c008380c32ecfd0ad417bcd0417ba432e6ba7328caa", + "sha256:cda422d54ee7905bfc53ee6915ab68fe7b230cacf581110df4272ee10462aadc", + "sha256:d3148b6ba3923c5850ea197a91a42683f946dba7e8eb82dfa211ab7e708de939", + "sha256:d6033b4ffa34ef70f0b8086fd4c3df4bf801fee485a8a7d4519399818351aa8e", + "sha256:ddff0b2bd7edcc8c82d1adde6dbbf5e60d57ce985402541cd2985c27f7bec2a0", + "sha256:e23cb7f1d8e0f93addf0cae3c5b6f00324cccb4a7949ee558d7b6ca973ab8ae9", + "sha256:effd2ba52cee4ceff1a77f20d2a9f9bf8d50353c854a282b8760ac15b9833168", + "sha256:f90c2267101010de42f7273c94a1f026e56cbc043f9330acd8a80e64300aba33", + "sha256:f960375e9823ae6a07072ff7f8a85954e5a6434f97869f50d0e41649a1c8144f", + "sha256:fcf32bf76dc25e30ed793145a57426064520890d7c02866eb93d3e4abe516948" + ], + "version": "==1.14.1" }, "chardet": { "hashes": [ @@ -216,49 +216,55 @@ }, "hiredis": { "hashes": [ - "sha256:01b577f84c20ecc9c07fc4c184231b08e3c3942de096fa99978e053de231c423", - "sha256:01ff0900134166961c9e339df77c33b72f7edc5cb41739f0babcd9faa345926e", - "sha256:03ed34a13316d0c34213c4fd46e0fa3a5299073f4d4f08e93fed8c2108b399b3", - "sha256:040436e91df5143aff9e0debb49530d0b17a6bd52200ce568621c31ef581b10d", - "sha256:091eb38fbf968d1c5b703e412bbbd25f43a7967d8400842cee33a5a07b33c27b", - "sha256:102f9b9dc6ed57feb3a7c9bdf7e71cb7c278fe8df1edfcfe896bc3e0c2be9447", - "sha256:2b4b392c7e3082860c8371fab3ae762139090f9115819e12d9f56060f9ede05d", - "sha256:2c9cc0b986397b833073f466e6b9e9c70d1d4dc2c2c1b3e9cae3a23102ff296c", - "sha256:2fa65a9df683bca72073cd77709ddeb289ea2b114d3775d225fbbcc5faf808c5", - "sha256:38437a681f17c975fd22349e72c29bc643f8e7eb2d6dc5df419eac59afa4d7ce", - "sha256:3b3428fa3cf1ee178807b52c9bee8950ab94cd4eaa9bfae8c1bbae3c49501d34", - "sha256:3dd8c2fae7f5494978facb0e93297dd627b1a3f536f3b070cf0a7d9157a07dcb", - "sha256:4414a96c212e732723b5c3d7c04d386ebbb2ec359e1de646322cbc3f875cbd0d", - "sha256:48c627581ad4ef60adbac980981407939acf13a0e18f093502c7b542223c4f19", - "sha256:4a60e71625a2d78d8ab84dfb2fa2cfd9458c964b6e6c04fea76d9ade153fb371", - "sha256:585ace09f434e43d8a8dbeb366865b1a044d7c06319b3c7372a0a00e63b860f4", - "sha256:74b364b3f06c9cf0a53f7df611045bc9437ed972a283fa1f0b12537236d23ddc", - "sha256:75c65c3850e89e9daa68d1b9bedd5806f177d60aa5a7b0953b4829481cfc1f72", - "sha256:7f052de8bf744730a9120dbdc67bfeb7605a01f69fb8e7ba5c475af33c24e145", - "sha256:8113a7d5e87ecf57cd4ae263cc9e429adb9a3e59f5a7768da5d3312a8d0a051a", - "sha256:84857ce239eb8ed191ac78e77ff65d52902f00f30f4ee83bf80eb71da73b70e6", - "sha256:8644a48ddc4a40b3e3a6b9443f396c2ee353afb2d45656c4fc68d04a82e8e3f7", - "sha256:936aa565e673536e8a211e43ec43197406f24cd1f290138bd143765079c8ba00", - "sha256:9afeb88c67bbc663b9f27385c496da056d06ad87f55df6e393e1516cfecb0461", - "sha256:9d62cc7880110e4f83b0a51d218f465d3095e2751fbddd34e553dbd106a929ff", - "sha256:a1fadd062fc8d647ff39220c57ea2b48c99bb73f18223828ec97f88fc27e7898", - "sha256:a7754a783b1e5d6f627c19d099b178059c62f782ab62b4d8ba165b9fbc2ee34c", - "sha256:aa59dd63bb3f736de4fc2d080114429d5d369dfb3265f771778e8349d67a97a4", - "sha256:ae2ee0992f8de249715435942137843a93db204dd7db1e7cc9bdc5a8436443e8", - "sha256:b36842d7cf32929d568f37ec5b3173b72b2ec6572dec4d6be6ce774762215aee", - "sha256:bcbf9379c553b5facc6c04c1e5569b44b38ff16bcbf354676287698d61ee0c92", - "sha256:cbccbda6f1c62ab460449d9c85fdf24d0d32a6bf45176581151e53cc26a5d910", - "sha256:d0caf98dfb8af395d6732bd16561c0a2458851bea522e39f12f04802dbf6f502", - "sha256:d6456afeddba036def1a36d8a2758eca53202308d83db20ab5d0b66590919627", - "sha256:dbaef9a21a4f10bc281684ee4124f169e62bb533c2a92b55f8c06f64f9af7b8f", - "sha256:dce84916c09aaece006272b37234ae84a8ed13abb3a4d341a23933b8701abfb5", - "sha256:eb8c9c8b9869539d58d60ff4a28373a22514d40495911451343971cb4835b7a9", - "sha256:efc98b14ee3a8595e40b1425e8d42f5fd26f11a7b215a81ef9259068931754f4", - "sha256:fa2dc05b87d97acc1c6ae63f3e0f39eae5246565232484b08db6bf2dc1580678", - "sha256:fe7d6ce9f6a5fbe24f09d95ea93e9c7271abc4e1565da511e1449b107b4d7848" + "sha256:06a039208f83744a702279b894c8cf24c14fd63c59cd917dcde168b79eef0680", + "sha256:0a909bf501459062aa1552be1461456518f367379fdc9fdb1f2ca5e4a1fdd7c0", + "sha256:18402d9e54fb278cb9a8c638df6f1550aca36a009d47ecf5aa263a38600f35b0", + "sha256:1e4cbbc3858ec7e680006e5ca590d89a5e083235988f26a004acf7244389ac01", + "sha256:23344e3c2177baf6975fbfa361ed92eb7d36d08f454636e5054b3faa7c2aff8a", + "sha256:289b31885b4996ce04cadfd5fc03d034dce8e2a8234479f7c9e23b9e245db06b", + "sha256:2c1c570ae7bf1bab304f29427e2475fe1856814312c4a1cf1cd0ee133f07a3c6", + "sha256:2c227c0ed371771ffda256034427320870e8ea2e4fd0c0a618c766e7c49aad73", + "sha256:3bb9b63d319402cead8bbd9dd55dca3b667d2997e9a0d8a1f9b6cc274db4baee", + "sha256:3ef2183de67b59930d2db8b8e8d4d58e00a50fcc5e92f4f678f6eed7a1c72d55", + "sha256:43b8ed3dbfd9171e44c554cb4acf4ee4505caa84c5e341858b50ea27dd2b6e12", + "sha256:47bcf3c5e6c1e87ceb86cdda2ee983fa0fe56a999e6185099b3c93a223f2fa9b", + "sha256:5263db1e2e1e8ae30500cdd75a979ff99dcc184201e6b4b820d0de74834d2323", + "sha256:5b1451727f02e7acbdf6aae4e06d75f66ee82966ff9114550381c3271a90f56c", + "sha256:6996883a8a6ff9117cbb3d6f5b0dcbbae6fb9e31e1a3e4e2f95e0214d9a1c655", + "sha256:6c96f64a54f030366657a54bb90b3093afc9c16c8e0dfa29fc0d6dbe169103a5", + "sha256:7332d5c3e35154cd234fd79573736ddcf7a0ade7a986db35b6196b9171493e75", + "sha256:7885b6f32c4a898e825bb7f56f36a02781ac4a951c63e4169f0afcf9c8c30dfb", + "sha256:7b0f63f10a166583ab744a58baad04e0f52cfea1ac27bfa1b0c21a48d1003c23", + "sha256:819f95d4eba3f9e484dd115ab7ab72845cf766b84286a00d4ecf76d33f1edca1", + "sha256:8968eeaa4d37a38f8ca1f9dbe53526b69628edc9c42229a5b2f56d98bb828c1f", + "sha256:89ebf69cb19a33d625db72d2ac589d26e936b8f7628531269accf4a3196e7872", + "sha256:8daecd778c1da45b8bd54fd41ffcd471a86beed3d8e57a43acf7a8d63bba4058", + "sha256:955ba8ea73cf3ed8bd2f963b4cb9f8f0dcb27becd2f4b3dd536fd24c45533454", + "sha256:964f18a59f5a64c0170f684c417f4fe3e695a536612e13074c4dd5d1c6d7c882", + "sha256:969843fbdfbf56cdb71da6f0bdf50f9985b8b8aeb630102945306cf10a9c6af2", + "sha256:996021ef33e0f50b97ff2d6b5f422a0fe5577de21a8873b58a779a5ddd1c3132", + "sha256:9e9c9078a7ce07e6fce366bd818be89365a35d2e4b163268f0ca9ba7e13bb2f6", + "sha256:a04901757cb0fb0f5602ac11dda48f5510f94372144d06c2563ba56c480b467c", + "sha256:a7bf1492429f18d205f3a818da3ff1f242f60aa59006e53dee00b4ef592a3363", + "sha256:aa0af2deb166a5e26e0d554b824605e660039b161e37ed4f01b8d04beec184f3", + "sha256:abfb15a6a7822f0fae681785cb38860e7a2cb1616a708d53df557b3d76c5bfd4", + "sha256:b253fe4df2afea4dfa6b1fa8c5fef212aff8bcaaeb4207e81eed05cb5e4a7919", + "sha256:b27f082f47d23cffc4cf1388b84fdc45c4ef6015f906cd7e0d988d9e35d36349", + "sha256:b33aea449e7f46738811fbc6f0b3177c6777a572207412bbbf6f525ffed001ae", + "sha256:b44f9421c4505c548435244d74037618f452844c5d3c67719d8a55e2613549da", + "sha256:bcc371151d1512201d0214c36c0c150b1dc64f19c2b1a8c9cb1d7c7c15ebd93f", + "sha256:c2851deeabd96d3f6283e9c6b26e0bfed4de2dc6fb15edf913e78b79fc5909ed", + "sha256:cdfd501c7ac5b198c15df800a3a34c38345f5182e5f80770caf362bccca65628", + "sha256:d2c0caffa47606d6d7c8af94ba42547bd2a441f06c74fd90a1ffe328524a6c64", + "sha256:dcb2db95e629962db5a355047fb8aefb012df6c8ae608930d391619dbd96fd86", + "sha256:e0eeb9c112fec2031927a1745788a181d0eecbacbed941fc5c4f7bc3f7b273bf", + "sha256:e154891263306200260d7f3051982774d7b9ef35af3509d5adbbe539afd2610c", + "sha256:e2e023a42dcbab8ed31f97c2bcdb980b7fbe0ada34037d87ba9d799664b58ded", + "sha256:e64be68255234bb489a574c4f2f8df7029c98c81ec4d160d6cd836e7f0679390", + "sha256:e82d6b930e02e80e5109b678c663a9ed210680ded81c1abaf54635d88d1da298" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.0.1" + "version": "==1.1.0" }, "humanfriendly": { "hashes": [ @@ -294,36 +300,40 @@ }, "lxml": { "hashes": [ - "sha256:06748c7192eab0f48e3d35a7adae609a329c6257495d5e53878003660dc0fec6", - "sha256:0790ddca3f825dd914978c94c2545dbea5f56f008b050e835403714babe62a5f", - "sha256:1aa7a6197c1cdd65d974f3e4953764eee3d9c7b67e3966616b41fab7f8f516b7", - "sha256:22c6d34fdb0e65d5f782a4d1a1edb52e0a8365858dafb1c08cb1d16546cf0786", - "sha256:2754d4406438c83144f9ffd3628bbe2dcc6d62b20dbc5c1ec4bc4385e5d44b42", - "sha256:27ee0faf8077c7c1a589573b1450743011117f1aa1a91d5ae776bbc5ca6070f2", - "sha256:2b02c106709466a93ed424454ce4c970791c486d5fcdf52b0d822a7e29789626", - "sha256:2d1ddce96cf15f1254a68dba6935e6e0f1fe39247de631c115e84dd404a6f031", - "sha256:4f282737d187ae723b2633856085c31ae5d4d432968b7f3f478a48a54835f5c4", - "sha256:51bb4edeb36d24ec97eb3e6a6007be128b720114f9a875d6b370317d62ac80b9", - "sha256:7eee37c1b9815e6505847aa5e68f192e8a1b730c5c7ead39ff317fde9ce29448", - "sha256:7fd88cb91a470b383aafad554c3fe1ccf6dfb2456ff0e84b95335d582a799804", - "sha256:9144ce36ca0824b29ebc2e02ca186e54040ebb224292072250467190fb613b96", - "sha256:925baf6ff1ef2c45169f548cc85204433e061360bfa7d01e1be7ae38bef73194", - "sha256:a636346c6c0e1092ffc202d97ec1843a75937d8c98aaf6771348ad6422e44bb0", - "sha256:a87dbee7ad9dce3aaefada2081843caf08a44a8f52e03e0a4cc5819f8398f2f4", - "sha256:a9e3b8011388e7e373565daa5e92f6c9cb844790dc18e43073212bb3e76f7007", - "sha256:afb53edf1046599991fb4a7d03e601ab5f5422a5435c47ee6ba91ec3b61416a6", - "sha256:b26719890c79a1dae7d53acac5f089d66fd8cc68a81f4e4bd355e45470dc25e1", - "sha256:b7462cdab6fffcda853338e1741ce99706cdf880d921b5a769202ea7b94e8528", - "sha256:b77975465234ff49fdad871c08aa747aae06f5e5be62866595057c43f8d2f62c", - "sha256:c47a8a5d00060122ca5908909478abce7bbf62d812e3fc35c6c802df8fb01fe7", - "sha256:c79e5debbe092e3c93ca4aee44c9a7631bdd407b2871cb541b979fd350bbbc29", - "sha256:d8d40e0121ca1606aa9e78c28a3a7d88a05c06b3ca61630242cded87d8ce55fa", - "sha256:ee2be8b8f72a2772e72ab926a3bccebf47bb727bda41ae070dc91d1fb759b726", - "sha256:f95d28193c3863132b1f55c1056036bf580b5a488d908f7d22a04ace8935a3a9", - "sha256:fadd2a63a2bfd7fb604508e553d1cf68eca250b2fbdbd81213b5f6f2fbf23529" - ], - "index": "pypi", - "version": "==4.5.1" + "sha256:05a444b207901a68a6526948c7cc8f9fe6d6f24c70781488e32fd74ff5996e3f", + "sha256:08fc93257dcfe9542c0a6883a25ba4971d78297f63d7a5a26ffa34861ca78730", + "sha256:107781b213cf7201ec3806555657ccda67b1fccc4261fb889ef7fc56976db81f", + "sha256:121b665b04083a1e85ff1f5243d4a93aa1aaba281bc12ea334d5a187278ceaf1", + "sha256:1fa21263c3aba2b76fd7c45713d4428dbcc7644d73dcf0650e9d344e433741b3", + "sha256:2b30aa2bcff8e958cd85d907d5109820b01ac511eae5b460803430a7404e34d7", + "sha256:4b4a111bcf4b9c948e020fd207f915c24a6de3f1adc7682a2d92660eb4e84f1a", + "sha256:5591c4164755778e29e69b86e425880f852464a21c7bb53c7ea453bbe2633bbe", + "sha256:59daa84aef650b11bccd18f99f64bfe44b9f14a08a28259959d33676554065a1", + "sha256:5a9c8d11aa2c8f8b6043d845927a51eb9102eb558e3f936df494e96393f5fd3e", + "sha256:5dd20538a60c4cc9a077d3b715bb42307239fcd25ef1ca7286775f95e9e9a46d", + "sha256:74f48ec98430e06c1fa8949b49ebdd8d27ceb9df8d3d1c92e1fdc2773f003f20", + "sha256:786aad2aa20de3dbff21aab86b2fb6a7be68064cbbc0219bde414d3a30aa47ae", + "sha256:7ad7906e098ccd30d8f7068030a0b16668ab8aa5cda6fcd5146d8d20cbaa71b5", + "sha256:80a38b188d20c0524fe8959c8ce770a8fdf0e617c6912d23fc97c68301bb9aba", + "sha256:8f0ec6b9b3832e0bd1d57af41f9238ea7709bbd7271f639024f2fc9d3bb01293", + "sha256:92282c83547a9add85ad658143c76a64a8d339028926d7dc1998ca029c88ea6a", + "sha256:94150231f1e90c9595ccc80d7d2006c61f90a5995db82bccbca7944fd457f0f6", + "sha256:9dc9006dcc47e00a8a6a029eb035c8f696ad38e40a27d073a003d7d1443f5d88", + "sha256:a76979f728dd845655026ab991df25d26379a1a8fc1e9e68e25c7eda43004bed", + "sha256:aa8eba3db3d8761db161003e2d0586608092e217151d7458206e243be5a43843", + "sha256:bea760a63ce9bba566c23f726d72b3c0250e2fa2569909e2d83cda1534c79443", + "sha256:c3f511a3c58676147c277eff0224c061dd5a6a8e1373572ac817ac6324f1b1e0", + "sha256:c9d317efde4bafbc1561509bfa8a23c5cab66c44d49ab5b63ff690f5159b2304", + "sha256:cc411ad324a4486b142c41d9b2b6a722c534096963688d879ea6fa8a35028258", + "sha256:cdc13a1682b2a6241080745b1953719e7fe0850b40a5c71ca574f090a1391df6", + "sha256:cfd7c5dd3c35c19cec59c63df9571c67c6d6e5c92e0fe63517920e97f61106d1", + "sha256:e1cacf4796b20865789083252186ce9dc6cc59eca0c2e79cca332bdff24ac481", + "sha256:e70d4e467e243455492f5de463b72151cc400710ac03a0678206a5f27e79ddef", + "sha256:ecc930ae559ea8a43377e8b60ca6f8d61ac532fc57efb915d899de4a67928efd", + "sha256:f161af26f596131b63b236372e4ce40f3167c1b5b5d459b29d2514bd8c9dc9ee" + ], + "index": "pypi", + "version": "==4.5.2" }, "markdownify": { "hashes": [ @@ -532,11 +542,11 @@ }, "sentry-sdk": { "hashes": [ - "sha256:da06bc3641e81ec2c942f87a0676cd9180044fa3d1697524a0005345997542e2", - "sha256:e80d61af85d99a1222c1a3e2a24023618374cd50a99673aa7fa3cf920e7d813b" + "sha256:2de15b13836fa3522815a933bd9c887c77f4868071043349f94f1b896c1bcfb8", + "sha256:38bb09d0277117f76507c8728d9a5156f09a47ac5175bb8072513859d19a593b" ], "index": "pypi", - "version": "==0.16.0" + "version": "==0.16.2" }, "six": { "hashes": [ @@ -632,13 +642,21 @@ "index": "pypi", "version": "==3.3.0" }, + "typing-extensions": { + "hashes": [ + "sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5", + "sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae", + "sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392" + ], + "version": "==3.7.4.2" + }, "urllib3": { "hashes": [ - "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", - "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115" + "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a", + "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", - "version": "==1.25.9" + "version": "==1.25.10" }, "websockets": { "hashes": [ @@ -670,26 +688,26 @@ }, "yarl": { "hashes": [ - "sha256:0c2ab325d33f1b824734b3ef51d4d54a54e0e7a23d13b86974507602334c2cce", - "sha256:0ca2f395591bbd85ddd50a82eb1fde9c1066fafe888c5c7cc1d810cf03fd3cc6", - "sha256:2098a4b4b9d75ee352807a95cdf5f10180db903bc5b7270715c6bbe2551f64ce", - "sha256:25e66e5e2007c7a39541ca13b559cd8ebc2ad8fe00ea94a2aad28a9b1e44e5ae", - "sha256:26d7c90cb04dee1665282a5d1a998defc1a9e012fdca0f33396f81508f49696d", - "sha256:308b98b0c8cd1dfef1a0311dc5e38ae8f9b58349226aa0533f15a16717ad702f", - "sha256:3ce3d4f7c6b69c4e4f0704b32eca8123b9c58ae91af740481aa57d7857b5e41b", - "sha256:58cd9c469eced558cd81aa3f484b2924e8897049e06889e8ff2510435b7ef74b", - "sha256:5b10eb0e7f044cf0b035112446b26a3a2946bca9d7d7edb5e54a2ad2f6652abb", - "sha256:6faa19d3824c21bcbfdfce5171e193c8b4ddafdf0ac3f129ccf0cdfcb083e462", - "sha256:944494be42fa630134bf907714d40207e646fd5a94423c90d5b514f7b0713fea", - "sha256:a161de7e50224e8e3de6e184707476b5a989037dcb24292b391a3d66ff158e70", - "sha256:a4844ebb2be14768f7994f2017f70aca39d658a96c786211be5ddbe1c68794c1", - "sha256:c2b509ac3d4b988ae8769901c66345425e361d518aecbe4acbfc2567e416626a", - "sha256:c9959d49a77b0e07559e579f38b2f3711c2b8716b8410b320bf9713013215a1b", - "sha256:d8cdee92bc930d8b09d8bd2043cedd544d9c8bd7436a77678dd602467a993080", - "sha256:e15199cdb423316e15f108f51249e44eb156ae5dba232cb73be555324a1d49c2" + "sha256:1707230e1ea48ea06a3e20acb4ce05a38d2465bd9566c21f48f6212a88e47536", + "sha256:1f269e8e6676193a94635399a77c9059e1826fb6265c9204c9e5a8ccd36006e1", + "sha256:2657716c1fc998f5f2675c0ee6ce91282e0da0ea9e4a94b584bb1917e11c1559", + "sha256:431faa6858f0ea323714d8b7b4a7da1db2eeb9403607f0eaa3800ab2c5a4b627", + "sha256:5bbcb195da7de57f4508b7508c33f7593e9516e27732d08b9aad8586c7b8c384", + "sha256:5c82f5b1499342339f22c83b97dbe2b8a09e47163fab86cd934a8dd46620e0fb", + "sha256:5d410f69b4f92c5e1e2a8ffb73337cd8a274388c6975091735795588a538e605", + "sha256:66b4f345e9573e004b1af184bc00431145cf5e089a4dcc1351505c1f5750192c", + "sha256:875b2a741ce0208f3b818008a859ab5d0f461e98a32bbdc6af82231a9e761c55", + "sha256:9a3266b047d15e78bba38c8455bf68b391c040231ca5965ef867f7cbbc60bde5", + "sha256:9a592c4aa642249e9bdaf76897d90feeb08118626b363a6be8788a9b300274b5", + "sha256:a1772068401d425e803999dada29a6babf041786e08be5e79ef63c9ecc4c9575", + "sha256:b065a5c3e050395ae563019253cc6c769a50fd82d7fa92d07476273521d56b7c", + "sha256:b325fefd574ebef50e391a1072d1712a60348ca29c183e1d546c9d87fec2cd32", + "sha256:cf5eb664910d759bbae0b76d060d6e21f8af5098242d66c448bbebaf2a7bfa70", + "sha256:f058b6541477022c7b54db37229f87dacf3b565de4f901ff5a0a78556a174fea", + "sha256:f5cfed0766837303f688196aa7002730d62c5cc802d98c6395ea1feb87252727" ], "markers": "python_version >= '3.5'", - "version": "==1.4.2" + "version": "==1.5.0" } }, "develop": { @@ -718,43 +736,43 @@ }, "coverage": { "hashes": [ - "sha256:0fc4e0d91350d6f43ef6a61f64a48e917637e1dcfcba4b4b7d543c628ef82c2d", - "sha256:10f2a618a6e75adf64329f828a6a5b40244c1c50f5ef4ce4109e904e69c71bd2", - "sha256:12eaccd86d9a373aea59869bc9cfa0ab6ba8b1477752110cb4c10d165474f703", - "sha256:1874bdc943654ba46d28f179c1846f5710eda3aeb265ff029e0ac2b52daae404", - "sha256:1dcebae667b73fd4aa69237e6afb39abc2f27520f2358590c1b13dd90e32abe7", - "sha256:1e58fca3d9ec1a423f1b7f2aa34af4f733cbfa9020c8fe39ca451b6071237405", - "sha256:214eb2110217f2636a9329bc766507ab71a3a06a8ea30cdeebb47c24dce5972d", - "sha256:25fe74b5b2f1b4abb11e103bb7984daca8f8292683957d0738cd692f6a7cc64c", - "sha256:32ecee61a43be509b91a526819717d5e5650e009a8d5eda8631a59c721d5f3b6", - "sha256:3740b796015b889e46c260ff18b84683fa2e30f0f75a171fb10d2bf9fb91fc70", - "sha256:3b2c34690f613525672697910894b60d15800ac7e779fbd0fccf532486c1ba40", - "sha256:41d88736c42f4a22c494c32cc48a05828236e37c991bd9760f8923415e3169e4", - "sha256:42fa45a29f1059eda4d3c7b509589cc0343cd6bbf083d6118216830cd1a51613", - "sha256:4bb385a747e6ae8a65290b3df60d6c8a692a5599dc66c9fa3520e667886f2e10", - "sha256:509294f3e76d3f26b35083973fbc952e01e1727656d979b11182f273f08aa80b", - "sha256:5c74c5b6045969b07c9fb36b665c9cac84d6c174a809fc1b21bdc06c7836d9a0", - "sha256:60a3d36297b65c7f78329b80120f72947140f45b5c7a017ea730f9112b40f2ec", - "sha256:6f91b4492c5cde83bfe462f5b2b997cdf96a138f7c58b1140f05de5751623cf1", - "sha256:7403675df5e27745571aba1c957c7da2dacb537c21e14007ec3a417bf31f7f3d", - "sha256:87bdc8135b8ee739840eee19b184804e5d57f518578ffc797f5afa2c3c297913", - "sha256:8a3decd12e7934d0254939e2bf434bf04a5890c5bf91a982685021786a08087e", - "sha256:9702e2cb1c6dec01fb8e1a64c015817c0800a6eca287552c47a5ee0ebddccf62", - "sha256:a4d511012beb967a39580ba7d2549edf1e6865a33e5fe51e4dce550522b3ac0e", - "sha256:bbb387811f7a18bdc61a2ea3d102be0c7e239b0db9c83be7bfa50f095db5b92a", - "sha256:bfcc811883699ed49afc58b1ed9f80428a18eb9166422bce3c31a53dba00fd1d", - "sha256:c32aa13cc3fe86b0f744dfe35a7f879ee33ac0a560684fef0f3e1580352b818f", - "sha256:ca63dae130a2e788f2b249200f01d7fa240f24da0596501d387a50e57aa7075e", - "sha256:d54d7ea74cc00482a2410d63bf10aa34ebe1c49ac50779652106c867f9986d6b", - "sha256:d67599521dff98ec8c34cd9652cbcfe16ed076a2209625fca9dc7419b6370e5c", - "sha256:d82db1b9a92cb5c67661ca6616bdca6ff931deceebb98eecbd328812dab52032", - "sha256:d9ad0a988ae20face62520785ec3595a5e64f35a21762a57d115dae0b8fb894a", - "sha256:ebf2431b2d457ae5217f3a1179533c456f3272ded16f8ed0b32961a6d90e38ee", - "sha256:ed9a21502e9223f563e071759f769c3d6a2e1ba5328c31e86830368e8d78bc9c", - "sha256:f50632ef2d749f541ca8e6c07c9928a37f87505ce3a9f20c8446ad310f1aa87b" - ], - "index": "pypi", - "version": "==5.2" + "sha256:098a703d913be6fbd146a8c50cc76513d726b022d170e5e98dc56d958fd592fb", + "sha256:16042dc7f8e632e0dcd5206a5095ebd18cb1d005f4c89694f7f8aafd96dd43a3", + "sha256:1adb6be0dcef0cf9434619d3b892772fdb48e793300f9d762e480e043bd8e716", + "sha256:27ca5a2bc04d68f0776f2cdcb8bbd508bbe430a7bf9c02315cd05fb1d86d0034", + "sha256:28f42dc5172ebdc32622a2c3f7ead1b836cdbf253569ae5673f499e35db0bac3", + "sha256:2fcc8b58953d74d199a1a4d633df8146f0ac36c4e720b4a1997e9b6327af43a8", + "sha256:304fbe451698373dc6653772c72c5d5e883a4aadaf20343592a7abb2e643dae0", + "sha256:30bc103587e0d3df9e52cd9da1dd915265a22fad0b72afe54daf840c984b564f", + "sha256:40f70f81be4d34f8d491e55936904db5c527b0711b2a46513641a5729783c2e4", + "sha256:4186fc95c9febeab5681bc3248553d5ec8c2999b8424d4fc3a39c9cba5796962", + "sha256:46794c815e56f1431c66d81943fa90721bb858375fb36e5903697d5eef88627d", + "sha256:4869ab1c1ed33953bb2433ce7b894a28d724b7aa76c19b11e2878034a4e4680b", + "sha256:4f6428b55d2916a69f8d6453e48a505c07b2245653b0aa9f0dee38785939f5e4", + "sha256:52f185ffd3291196dc1aae506b42e178a592b0b60a8610b108e6ad892cfc1bb3", + "sha256:538f2fd5eb64366f37c97fdb3077d665fa946d2b6d95447622292f38407f9258", + "sha256:64c4f340338c68c463f1b56e3f2f0423f7b17ba6c3febae80b81f0e093077f59", + "sha256:675192fca634f0df69af3493a48224f211f8db4e84452b08d5fcebb9167adb01", + "sha256:700997b77cfab016533b3e7dbc03b71d33ee4df1d79f2463a318ca0263fc29dd", + "sha256:8505e614c983834239f865da2dd336dcf9d72776b951d5dfa5ac36b987726e1b", + "sha256:962c44070c281d86398aeb8f64e1bf37816a4dfc6f4c0f114756b14fc575621d", + "sha256:9e536783a5acee79a9b308be97d3952b662748c4037b6a24cbb339dc7ed8eb89", + "sha256:9ea749fd447ce7fb1ac71f7616371f04054d969d412d37611716721931e36efd", + "sha256:a34cb28e0747ea15e82d13e14de606747e9e484fb28d63c999483f5d5188e89b", + "sha256:a3ee9c793ffefe2944d3a2bd928a0e436cd0ac2d9e3723152d6fd5398838ce7d", + "sha256:aab75d99f3f2874733946a7648ce87a50019eb90baef931698f96b76b6769a46", + "sha256:b1ed2bdb27b4c9fc87058a1cb751c4df8752002143ed393899edb82b131e0546", + "sha256:b360d8fd88d2bad01cb953d81fd2edd4be539df7bfec41e8753fe9f4456a5082", + "sha256:b8f58c7db64d8f27078cbf2a4391af6aa4e4767cc08b37555c4ae064b8558d9b", + "sha256:c1bbb628ed5192124889b51204de27c575b3ffc05a5a91307e7640eff1d48da4", + "sha256:c2ff24df02a125b7b346c4c9078c8936da06964cc2d276292c357d64378158f8", + "sha256:c890728a93fffd0407d7d37c1e6083ff3f9f211c83b4316fae3778417eab9811", + "sha256:c96472b8ca5dc135fb0aa62f79b033f02aa434fb03a8b190600a5ae4102df1fd", + "sha256:ce7866f29d3025b5b34c2e944e66ebef0d92e4a4f2463f7266daa03a1332a651", + "sha256:e26c993bd4b220429d4ec8c1468eca445a4064a61c74ca08da7429af9bc53bb0" + ], + "index": "pypi", + "version": "==5.2.1" }, "distlib": { "hashes": [ @@ -780,11 +798,11 @@ }, "flake8-annotations": { "hashes": [ - "sha256:babc81a17a5f1a63464195917e20d3e8663fb712b3633d4522dbfc407cff31b3", - "sha256:fcd833b415726a7a374922c95a5c47a7a4d8ea71cb4a586369c665e7476146e1" + "sha256:7816a5d8f65ffdf37b8e21e5b17e0fd1e492aa92638573276de066e889a22b26", + "sha256:8d18db74a750dd97f40b483cc3ef80d07d03f687525bad8fd83365dcd3bfd414" ], "index": "pypi", - "version": "==2.2.0" + "version": "==2.3.0" }, "flake8-bugbear": { "hashes": [ @@ -842,11 +860,11 @@ }, "identify": { "hashes": [ - "sha256:c4d07f2b979e3931894170a9e0d4b8281e6905ea6d018c326f7ffefaf20db680", - "sha256:dac33eff90d57164e289fb20bf4e131baef080947ee9bf45efcd0da8d19064bf" + "sha256:110ed090fec6bce1aabe3c72d9258a9de82207adeaa5a05cd75c635880312f9a", + "sha256:ccd88716b890ecbe10920659450a635d2d25de499b9a638525a48b48261d989b" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.4.21" + "version": "==1.4.25" }, "mccabe": { "hashes": [ @@ -950,11 +968,11 @@ }, "virtualenv": { "hashes": [ - "sha256:c11a475400e98450403c0364eb3a2d25d42f71cf1493da64390487b666de4324", - "sha256:e10cc66f40cbda459720dfe1d334c4dc15add0d80f09108224f171006a97a172" + "sha256:688a61d7976d82b92f7906c367e83bb4b3f0af96f8f75bfcd3da95608fe8ac6c", + "sha256:8f582a030156282a9ee9d319984b759a232b07f86048c1d6a9e394afa44e78c8" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.0.26" + "version": "==20.0.28" } } } -- cgit v1.2.3 From 71b9ab4e1e9d75e9240bc0c6825f43d978ef922b Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 27 Jul 2020 12:18:25 +0200 Subject: Update IDs of Code Jam roles I've updated the IDs of the two Code Jam Roles to the newly create roles we have. --- config-default.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config-default.yml b/config-default.yml index d0262be33..fc093cc32 100644 --- a/config-default.yml +++ b/config-default.yml @@ -236,8 +236,8 @@ guild: owners: &OWNERS_ROLE 267627879762755584 # Code Jam - jammers: 591786436651646989 - team_leaders: 501324292341104650 + jammers: 737249140966162473 + team_leaders: 737250302834638889 moderation_roles: - *OWNERS_ROLE -- cgit v1.2.3 From 1c6e2f23a9b75be5e2a7e410c70fadfbf6c6b090 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Tue, 28 Jul 2020 16:04:10 +0800 Subject: Allow specifying a channel to send !embed embeds --- bot/cogs/bot.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index a79b37d25..79510739c 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -72,10 +72,14 @@ class BotCog(Cog, name="Bot"): @command(name='embed') @with_role(*MODERATION_ROLES) - async def embed_command(self, ctx: Context, *, text: str) -> None: - """Send the input within an embed to the current channel.""" + async def embed_command(self, ctx: Context, channel: Optional[TextChannel], *, text: str) -> None: + """Send the input within an embed to either a specified channel or the current channel.""" embed = Embed(description=text) - await ctx.send(embed=embed) + + if channel is None: + await ctx.send(embed=embed) + else: + await channel.send(embed=embed) def codeblock_stripping(self, msg: str, bad_ticks: bool) -> Optional[Tuple[Tuple[str, ...], str]]: """ -- cgit v1.2.3 From 3fa1a0d03446500246f94df74fc3e3f86afe91ca Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Tue, 28 Jul 2020 10:22:39 +0200 Subject: fix poll command by using clean_content converter --- bot/cogs/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 017f3419e..11b8e3e5e 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -7,7 +7,7 @@ from io import StringIO from typing import Tuple, Union from discord import Colour, Embed, utils -from discord.ext.commands import BadArgument, Cog, Context, command +from discord.ext.commands import BadArgument, Cog, Context, command, clean_content from bot.bot import Bot from bot.constants import Channels, MODERATION_ROLES, STAFF_ROLES @@ -225,7 +225,7 @@ class Utils(Cog): @command(aliases=("poll",)) @with_role(*MODERATION_ROLES) - async def vote(self, ctx: Context, title: str, *options: str) -> None: + async def vote(self, ctx: Context, title: clean_content(fix_channel_mentions=True), *options: str) -> None: """ Build a quick voting poll with matching reactions with the provided options. -- cgit v1.2.3 From 8be7126e17e1fa9f671ab8acc52cb6d495084bd1 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Tue, 28 Jul 2020 10:30:00 +0200 Subject: correct import order --- bot/cogs/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 11b8e3e5e..91c6cb36e 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -7,7 +7,7 @@ from io import StringIO from typing import Tuple, Union from discord import Colour, Embed, utils -from discord.ext.commands import BadArgument, Cog, Context, command, clean_content +from discord.ext.commands import BadArgument, Cog, Context, clean_content, command from bot.bot import Bot from bot.constants import Channels, MODERATION_ROLES, STAFF_ROLES -- cgit v1.2.3 From f3fb8190c7c9541146ed79df2b0ad906fc067414 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 28 Jul 2020 21:24:25 +0300 Subject: Handle message unpinning better --- bot/cogs/help_channels.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index b06934eff..2c53069f0 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -556,8 +556,11 @@ class HelpChannels(Scheduler, commands.Cog): try: await self.bot.http.unpin_message(channel.id, msg_id) - except discord.HTTPException: - log.trace(f"Message {msg_id} don't exist, can't unpin.") + except discord.HTTPException as e: + if e.code == 10008: + log.trace(f"Message {msg_id} don't exist, can't unpin.") + else: + log.warn(f"Got unexpected status {e.code} when unpinning message {msg_id}: {e.text}") else: log.trace(f"Unpinned message {msg_id}.") -- cgit v1.2.3 From a4e5044596492fe56f2c2d36468f126907602b98 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 13:18:06 +0200 Subject: Don't ping everyone when tripping filter in DMs. We don't need a ping in #mod-alerts whenever someone is tripping a filter (like invites or bad language) in a DM to the bot. We can still send an embed, so that we can action it, but there is no urgent need to respond if it's just a direct message to the bot. This is particularly true now that we have #dm-log. --- bot/cogs/filtering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index bd665f424..29aac812f 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -339,7 +339,7 @@ class Filtering(Cog): text=message, thumbnail=msg.author.avatar_url_as(static_format="png"), channel_id=Channels.mod_alerts, - ping_everyone=Filter.ping_everyone, + ping_everyone=Filter.ping_everyone if not is_private else False, additional_embeds=additional_embeds, additional_embeds_msg=additional_embeds_msg ) -- cgit v1.2.3 From 1b6be865eddeab9199177d74ec07f8cd22051ca4 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 14:17:44 +0200 Subject: Expect status 400 for duplicates. --- bot/cogs/filter_lists.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py index d1db9830e..9bd2da330 100644 --- a/bot/cogs/filter_lists.py +++ b/bot/cogs/filter_lists.py @@ -64,10 +64,10 @@ class FilterLists(Cog): json=payload ) except ResponseCodeError as e: - if e.status == 500: + if e.status == 400: await ctx.message.add_reaction("❌") log.debug( - f"{ctx.author} tried to add data to a {allow_type}, but the API returned 500, " + f"{ctx.author} tried to add data to a {allow_type}, but the API returned 400, " "probably because the request violated the UniqueConstraint." ) raise BadArgument( -- cgit v1.2.3 From 222cdce0b9771b0c121da39b5f38363baf8bce09 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 14:25:00 +0200 Subject: Use a defaultdict(list) for filter_list_cache. --- bot/bot.py | 5 +++-- bot/cogs/filter_lists.py | 6 +++--- bot/cogs/filtering.py | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 3dfb4e948..a309e7192 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -2,6 +2,7 @@ import asyncio import logging import socket import warnings +from collections import defaultdict from typing import Optional import aiohttp @@ -34,7 +35,7 @@ class Bot(commands.Bot): self.redis_ready = asyncio.Event() self.redis_closed = False self.api_client = api.APIClient(loop=self.loop) - self.filter_list_cache = {} + self.filter_list_cache = defaultdict(list) self._connector = None self._resolver = None @@ -64,7 +65,7 @@ class Bot(commands.Bot): "created_at": item.get("created_at"), "updated_at": item.get("updated_at"), } - self.filter_list_cache.setdefault(f"{type_}.{allowed}", []).append(metadata) + self.filter_list_cache[f"{type_}.{allowed}"].append(metadata) async def _create_redis_session(self) -> None: """ diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py index 9bd2da330..63d74e421 100644 --- a/bot/cogs/filter_lists.py +++ b/bot/cogs/filter_lists.py @@ -88,7 +88,7 @@ class FilterLists(Cog): "created_at": item.get("created_at"), "updated_at": item.get("updated_at"), } - self.bot.filter_list_cache.setdefault(f"{type_}.{allowed}", []).append(metadata) + self.bot.filter_list_cache[f"{type_}.{allowed}"].append(metadata) await ctx.message.add_reaction("✅") async def _delete_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType, content: str) -> None: @@ -110,7 +110,7 @@ class FilterLists(Cog): # Find the content and delete it. log.trace(f"Trying to delete the {content} item from the {list_type} {allow_type}") - for allow_list in self.bot.filter_list_cache.get(f"{list_type}.{allowed}", []): + for allow_list in self.bot.filter_list_cache[f"{list_type}.{allowed}"]: if content == allow_list.get("content"): item = allow_list break @@ -125,7 +125,7 @@ class FilterLists(Cog): async def _list_all_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType) -> None: """Paginate and display all items in a filterlist.""" allow_type = "whitelist" if allowed else "blacklist" - result = self.bot.filter_list_cache.get(f"{list_type}.{allowed}", []) + result = self.bot.filter_list_cache[f"{list_type}.{allowed}"] # Build a list of lines we want to show in the paginator lines = [] diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 652af5ff5..9f9bcc464 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -101,7 +101,7 @@ class Filtering(Cog): def _get_filterlist_items(self, list_type: str, *, allowed: bool, compiled: Optional[bool] = False) -> list: """Fetch items from the filter_list_cache.""" - items = self.bot.filter_list_cache.get(f"{list_type.upper()}.{allowed}", []) + items = self.bot.filter_list_cache[f"{list_type.upper()}.{allowed}"] if compiled: return [re.compile(fr'{item["content"]}', flags=re.IGNORECASE) for item in items] -- cgit v1.2.3 From 1a0b2938dab931b6dc482a6b7a4b17549e0cf36f Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 14:30:48 +0200 Subject: Kaizen - group private methods together. --- bot/bot.py | 86 +++++++++++++++++++++++++++++++------------------------------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index a309e7192..3da5c0bb8 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -91,6 +91,49 @@ class Bot(commands.Bot): self.redis_closed = False self.redis_ready.set() + def _recreate(self) -> None: + """Re-create the connector, aiohttp session, the APIClient and the Redis session.""" + # Use asyncio for DNS resolution instead of threads so threads aren't spammed. + # Doesn't seem to have any state with regards to being closed, so no need to worry? + self._resolver = aiohttp.AsyncResolver() + + # Its __del__ does send a warning but it doesn't always show up for some reason. + if self._connector and not self._connector._closed: + log.warning( + "The previous connector was not closed; it will remain open and be overwritten" + ) + + if self.redis_session and not self.redis_session.closed: + log.warning( + "The previous redis pool was not closed; it will remain open and be overwritten" + ) + + # Create the redis session + self.loop.create_task(self._create_redis_session()) + + # Use AF_INET as its socket family to prevent HTTPS related problems both locally + # and in production. + self._connector = aiohttp.TCPConnector( + resolver=self._resolver, + family=socket.AF_INET, + ) + + # Client.login() will call HTTPClient.static_login() which will create a session using + # this connector attribute. + self.http.connector = self._connector + + # Its __del__ does send a warning but it doesn't always show up for some reason. + if self.http_session and not self.http_session.closed: + log.warning( + "The previous session was not closed; it will remain open and be overwritten" + ) + + self.http_session = aiohttp.ClientSession(connector=self._connector) + self.api_client.recreate(force=True, connector=self._connector) + + # Build the FilterList cache + self.loop.create_task(self._cache_filter_list_data()) + def add_cog(self, cog: commands.Cog) -> None: """Adds a "cog" to the bot and logs the operation.""" super().add_cog(cog) @@ -137,49 +180,6 @@ class Bot(commands.Bot): await self.stats.create_socket() await super().login(*args, **kwargs) - def _recreate(self) -> None: - """Re-create the connector, aiohttp session, the APIClient and the Redis session.""" - # Use asyncio for DNS resolution instead of threads so threads aren't spammed. - # Doesn't seem to have any state with regards to being closed, so no need to worry? - self._resolver = aiohttp.AsyncResolver() - - # Its __del__ does send a warning but it doesn't always show up for some reason. - if self._connector and not self._connector._closed: - log.warning( - "The previous connector was not closed; it will remain open and be overwritten" - ) - - if self.redis_session and not self.redis_session.closed: - log.warning( - "The previous redis pool was not closed; it will remain open and be overwritten" - ) - - # Create the redis session - self.loop.create_task(self._create_redis_session()) - - # Use AF_INET as its socket family to prevent HTTPS related problems both locally - # and in production. - self._connector = aiohttp.TCPConnector( - resolver=self._resolver, - family=socket.AF_INET, - ) - - # Client.login() will call HTTPClient.static_login() which will create a session using - # this connector attribute. - self.http.connector = self._connector - - # Its __del__ does send a warning but it doesn't always show up for some reason. - if self.http_session and not self.http_session.closed: - log.warning( - "The previous session was not closed; it will remain open and be overwritten" - ) - - self.http_session = aiohttp.ClientSession(connector=self._connector) - self.api_client.recreate(force=True, connector=self._connector) - - # Build the FilterList cache - self.loop.create_task(self._cache_filter_list_data()) - async def on_guild_available(self, guild: discord.Guild) -> None: """ Set the internal guild available event when constants.Guild.id becomes available. -- cgit v1.2.3 From a23b273734ddee5fd082bda4fa14aebfff1317ca Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 14:39:05 +0200 Subject: Make a helper for inserting filter lists. --- bot/bot.py | 26 +++++++++++++++----------- bot/cogs/filter_lists.py | 11 +---------- 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 3da5c0bb8..203b35ba0 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -3,7 +3,7 @@ import logging import socket import warnings from collections import defaultdict -from typing import Optional +from typing import Any, Dict, Optional import aiohttp import aioredis @@ -56,16 +56,7 @@ class Bot(commands.Bot): full_cache = await self.api_client.get('bot/filter-lists') for item in full_cache: - type_ = item.get("type") - allowed = item.get("allowed") - metadata = { - "content": item.get("content"), - "comment": item.get("comment"), - "id": item.get("id"), - "created_at": item.get("created_at"), - "updated_at": item.get("updated_at"), - } - self.filter_list_cache[f"{type_}.{allowed}"].append(metadata) + self.insert_item_into_filter_list_cache(item) async def _create_redis_session(self) -> None: """ @@ -174,6 +165,19 @@ class Bot(commands.Bot): self.redis_ready.clear() await self.redis_session.wait_closed() + def insert_item_into_filter_list_cache(self, item: Dict[Any]) -> None: + """Add an item to the bots filter_list_cache.""" + type_ = item["type"] + allowed = item["allowed"] + metadata = { + "id": item["id"], + "content": item["content"], + "comment": item["comment"], + "created_at": item["created_at"], + "updated_at": item["updated_at"], + } + self.filter_list_cache[f"{type_}.{allowed}"].append(metadata) + async def login(self, *args, **kwargs) -> None: """Re-create the connector and set up sessions before logging into Discord.""" self._recreate() diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py index 63d74e421..e0d057595 100644 --- a/bot/cogs/filter_lists.py +++ b/bot/cogs/filter_lists.py @@ -79,16 +79,7 @@ class FilterLists(Cog): raise # Insert the item into the cache - type_ = item.get("type") - allowed = item.get("allowed") - metadata = { - "content": item.get("content"), - "comment": item.get("comment"), - "id": item.get("id"), - "created_at": item.get("created_at"), - "updated_at": item.get("updated_at"), - } - self.bot.filter_list_cache[f"{type_}.{allowed}"].append(metadata) + self.bot.insert_item_into_filter_list_cache(item) await ctx.message.add_reaction("✅") async def _delete_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType, content: str) -> None: -- cgit v1.2.3 From a7a3e29ca901b84570e5a1ff1e4c2bcf22b86552 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 14:55:59 +0200 Subject: Make a helper for validating guild invites. --- bot/cogs/filter_lists.py | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py index e0d057595..a93de2de9 100644 --- a/bot/cogs/filter_lists.py +++ b/bot/cogs/filter_lists.py @@ -33,13 +33,7 @@ class FilterLists(Cog): # If this is a server invite, we gotta validate it. if list_type == "GUILD_INVITE": - log.trace(f"{content} is a guild invite, attempting to validate.") - validator = ValidDiscordServerInvite() - guild_data = await validator.convert(ctx, content) - - # If we make it this far without raising a BadArgument, the invite is - # valid. Let's convert the content to an ID. - log.trace(f"{content} validated as server invite. Converting to ID.") + guild_data = await self._validate_guild_invite(ctx, content) content = guild_data.get("id") # Unless the user has specified another comment, let's @@ -86,17 +80,10 @@ class FilterLists(Cog): """Remove an item from a filterlist.""" item = None allow_type = "whitelist" if allowed else "blacklist" - id_converter = IDConverter() # If this is a server invite, we need to convert it. - if list_type == "GUILD_INVITE" and not id_converter._get_id_match(content): - log.trace(f"{content} is a guild invite, attempting to validate.") - validator = ValidDiscordServerInvite() - guild_data = await validator.convert(ctx, content) - - # If we make it this far without raising a BadArgument, the invite is - # valid. Let's convert the content to an ID. - log.trace(f"{content} validated as server invite. Converting to ID.") + if list_type == "GUILD_INVITE" and not IDConverter()._get_id_match(content): + guild_data = await self._validate_guild_invite(ctx, content) content = guild_data.get("id") # Find the content and delete it. @@ -143,6 +130,21 @@ class FilterLists(Cog): embed.description = "Hmmm, seems like there's nothing here yet." await ctx.send(embed=embed) + async def _validate_guild_invite(self, ctx: Context, invite: str) -> dict: + """ + Validates a guild invite, and returns the guild info as a dict. + + Will raise a BadArgument if the guild invite is invalid. + """ + log.trace(f"Attempting to validate whether or not {invite} is a guild invite.") + validator = ValidDiscordServerInvite() + guild_data = await validator.convert(ctx, invite) + + # If we make it this far without raising a BadArgument, the invite is + # valid. Let's return a dict of guild information. + log.trace(f"{invite} validated as server invite. Converting to ID.") + return guild_data + @group(aliases=("allowlist", "allow", "al", "wl")) async def whitelist(self, ctx: Context) -> None: """Group for whitelisting commands.""" -- cgit v1.2.3 From 4e1609695762524bc707b6a8d39e88c2710cff6b Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 15:03:15 +0200 Subject: Refactor filtering: use non-compiled expressions. --- bot/cogs/filtering.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 9f9bcc464..7787d396d 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -99,14 +99,10 @@ class Filtering(Cog): self.bot.loop.create_task(self.reschedule_offensive_msg_deletion()) - def _get_filterlist_items(self, list_type: str, *, allowed: bool, compiled: Optional[bool] = False) -> list: + def _get_filterlist_items(self, list_type: str, *, allowed: bool) -> list: """Fetch items from the filter_list_cache.""" items = self.bot.filter_list_cache[f"{list_type.upper()}.{allowed}"] - - if compiled: - return [re.compile(fr'{item["content"]}', flags=re.IGNORECASE) for item in items] - else: - return [item["content"] for item in items] + return [item["content"] for item in items] @staticmethod def _expand_spoilers(text: str) -> str: @@ -143,9 +139,9 @@ class Filtering(Cog): def get_name_matches(self, name: str) -> List[re.Match]: """Check bad words from passed string (name). Return list of matches.""" matches = [] - watchlist_patterns = self._get_filterlist_items('word_watchlist', allowed=False, compiled=True) + watchlist_patterns = self._get_filterlist_items('word_watchlist', allowed=False) for pattern in watchlist_patterns: - if match := pattern.search(name): + if match := re.search(pattern, name, flags=re.IGNORECASE): matches.append(match) return matches @@ -408,9 +404,9 @@ class Filtering(Cog): if URL_RE.search(text): return False - watchlist_patterns = self._get_filterlist_items('word_watchlist', allowed=False, compiled=True) + watchlist_patterns = self._get_filterlist_items('word_watchlist', allowed=False) for pattern in watchlist_patterns: - match = pattern.search(text) + match = re.search(pattern, text, flags=re.IGNORECASE) if match: return match -- cgit v1.2.3 From e73589a0cc490187cb7aa3039628a29e1c1650c9 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 15:19:04 +0200 Subject: Fix imports in converters.py --- bot/converters.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 158bf1a16..77d0bead7 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -1,14 +1,15 @@ -import dateutil.parser -import dateutil.tz -import discord import logging import re import typing as t -from aiohttp import ClientConnectorError from datetime import datetime +from ssl import CertificateError + +import dateutil.parser +import dateutil.tz +import discord +from aiohttp import ClientConnectorError from dateutil.relativedelta import relativedelta from discord.ext.commands import BadArgument, Context, Converter, IDConverter, UserConverter -from ssl import CertificateError from bot.api import ResponseCodeError from bot.constants import URLs -- cgit v1.2.3 From e93cdca80026704d540c87e36a56ce059e8d5499 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 15:38:20 +0200 Subject: Fix a bad type annotation. --- bot/bot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 203b35ba0..5deb986ec 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -3,7 +3,7 @@ import logging import socket import warnings from collections import defaultdict -from typing import Any, Dict, Optional +from typing import Dict, Optional import aiohttp import aioredis @@ -165,7 +165,7 @@ class Bot(commands.Bot): self.redis_ready.clear() await self.redis_session.wait_closed() - def insert_item_into_filter_list_cache(self, item: Dict[Any]) -> None: + def insert_item_into_filter_list_cache(self, item: Dict[str, str]) -> None: """Add an item to the bots filter_list_cache.""" type_ = item["type"] allowed = item["allowed"] -- cgit v1.2.3 From e0837f4f6dd7c5c2d6fc0811dccfaf1ecae768ba Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 20:14:52 +0200 Subject: Restructure bot.filter_list_cache. This is an optimization designed to eliminate all the list comprehensions we were doing inside antimalware and filtering. The cache is now structured so that the content is the key and the metadata is the value. --- bot/bot.py | 8 ++++---- bot/cogs/antimalware.py | 2 +- bot/cogs/filter_lists.py | 18 +++++++++--------- bot/cogs/filtering.py | 3 +-- tests/bot/cogs/test_antimalware.py | 10 +++++----- 5 files changed, 20 insertions(+), 21 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 5deb986ec..4492feaa9 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -35,7 +35,7 @@ class Bot(commands.Bot): self.redis_ready = asyncio.Event() self.redis_closed = False self.api_client = api.APIClient(loop=self.loop) - self.filter_list_cache = defaultdict(list) + self.filter_list_cache = defaultdict(dict) self._connector = None self._resolver = None @@ -169,14 +169,14 @@ class Bot(commands.Bot): """Add an item to the bots filter_list_cache.""" type_ = item["type"] allowed = item["allowed"] - metadata = { + content = item["content"] + + self.filter_list_cache[f"{type_}.{allowed}"][content] = { "id": item["id"], - "content": item["content"], "comment": item["comment"], "created_at": item["created_at"], "updated_at": item["updated_at"], } - self.filter_list_cache[f"{type_}.{allowed}"].append(metadata) async def login(self, *args, **kwargs) -> None: """Re-create the connector and set up sessions before logging into Discord.""" diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index 9a100b3fc..c76bd2c60 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -40,7 +40,7 @@ class AntiMalware(Cog): def _get_whitelisted_file_formats(self) -> list: """Get the file formats currently on the whitelist.""" - return [item['content'] for item in self.bot.filter_list_cache['file_format.True']] + return self.bot.filter_list_cache['FILE_FORMAT.True'].keys() def _get_disallowed_extensions(self, message: Message) -> t.Iterable[str]: """Get an iterable containing all the disallowed extensions of attachments.""" diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py index a93de2de9..3331be014 100644 --- a/bot/cogs/filter_lists.py +++ b/bot/cogs/filter_lists.py @@ -88,16 +88,16 @@ class FilterLists(Cog): # Find the content and delete it. log.trace(f"Trying to delete the {content} item from the {list_type} {allow_type}") - for allow_list in self.bot.filter_list_cache[f"{list_type}.{allowed}"]: - if content == allow_list.get("content"): - item = allow_list + for allow_list, metadata in self.bot.filter_list_cache[f"{list_type}.{allowed}"].items(): + if content == allow_list: + item = metadata break if item is not None: await self.bot.api_client.delete( - f"bot/filter-lists/{item.get('id')}" + f"bot/filter-lists/{item['id']}" ) - self.bot.filter_list_cache[f"{list_type}.{allowed}"].remove(item) + del self.bot.filter_list_cache[f"{list_type}.{allowed}"][content] await ctx.message.add_reaction("✅") async def _list_all_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType) -> None: @@ -107,11 +107,11 @@ class FilterLists(Cog): # Build a list of lines we want to show in the paginator lines = [] - for item in result: - line = f"• `{item.get('content')}`" + for content, metadata in result.items(): + line = f"• `{content}`" - if item.get("comment"): - line += f" - {item.get('comment')}" + if metadata.get("comment"): + line += f" - {metadata.get('comment')}" lines.append(line) lines = sorted(lines) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 7787d396d..0951cb740 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -101,8 +101,7 @@ class Filtering(Cog): def _get_filterlist_items(self, list_type: str, *, allowed: bool) -> list: """Fetch items from the filter_list_cache.""" - items = self.bot.filter_list_cache[f"{list_type.upper()}.{allowed}"] - return [item["content"] for item in items] + return self.bot.filter_list_cache[f"{list_type.upper()}.{allowed}"].keys() @staticmethod def _expand_spoilers(text: str) -> str: diff --git a/tests/bot/cogs/test_antimalware.py b/tests/bot/cogs/test_antimalware.py index 664fa8f19..82eadf226 100644 --- a/tests/bot/cogs/test_antimalware.py +++ b/tests/bot/cogs/test_antimalware.py @@ -15,11 +15,11 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): """Sets up fresh objects for each test.""" self.bot = MockBot() self.bot.filter_list_cache = { - "file_format.True": [ - {"content": ".first"}, - {"content": ".second"}, - {"content": ".third"} - ] + "file_format.True": { + ".first": {}, + ".second": {}, + ".third": {}, + } } self.cog = antimalware.AntiMalware(self.bot) self.message = MockMessage() -- cgit v1.2.3 From 48bc968d3c03032beed8ac110b76dc468262a4d3 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 20:15:17 +0200 Subject: word_watchlist -> filter_token in filtering.py. --- bot/cogs/filtering.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 0951cb740..8670e1c8c 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -138,7 +138,7 @@ class Filtering(Cog): def get_name_matches(self, name: str) -> List[re.Match]: """Check bad words from passed string (name). Return list of matches.""" matches = [] - watchlist_patterns = self._get_filterlist_items('word_watchlist', allowed=False) + watchlist_patterns = self._get_filterlist_items('filter_token', allowed=False) for pattern in watchlist_patterns: if match := re.search(pattern, name, flags=re.IGNORECASE): matches.append(match) @@ -403,7 +403,7 @@ class Filtering(Cog): if URL_RE.search(text): return False - watchlist_patterns = self._get_filterlist_items('word_watchlist', allowed=False) + watchlist_patterns = self._get_filterlist_items('filter_token', allowed=False) for pattern in watchlist_patterns: match = re.search(pattern, text, flags=re.IGNORECASE) if match: -- cgit v1.2.3 From 13a5f35273da39aafdcda7b257364a7756b028ff Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 20:15:48 +0200 Subject: We search for an invite instead of matching one. This means we can validate invites that start with https://, whereas before we could not. --- bot/converters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/converters.py b/bot/converters.py index 77d0bead7..5912e3e61 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -57,7 +57,7 @@ class ValidDiscordServerInvite(Converter): async def convert(self, ctx: Context, server_invite: str) -> dict: """Check whether the string is a valid Discord server invite.""" - invite_code = INVITE_RE.match(server_invite) + invite_code = INVITE_RE.search(server_invite) if invite_code: response = await ctx.bot.http_session.get( f"{URLs.discord_invite_api}/{invite_code[1]}" -- cgit v1.2.3 From 0cfc918c6d68764c380f1188f3bc5508e6b27030 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 20:24:06 +0200 Subject: Fix broken antimalware tests. --- tests/bot/cogs/test_antimalware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_antimalware.py b/tests/bot/cogs/test_antimalware.py index 82eadf226..ecb7abf00 100644 --- a/tests/bot/cogs/test_antimalware.py +++ b/tests/bot/cogs/test_antimalware.py @@ -15,7 +15,7 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): """Sets up fresh objects for each test.""" self.bot = MockBot() self.bot.filter_list_cache = { - "file_format.True": { + "FILE_FORMAT.True": { ".first": {}, ".second": {}, ".third": {}, -- cgit v1.2.3 From dd3275e8a8552f9d7580f9e2a070e8fae1d41b5d Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 21:49:33 +0200 Subject: Apply suggested change from @MarkKoz. --- bot/cogs/filter_lists.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py index 3331be014..f133d53d9 100644 --- a/bot/cogs/filter_lists.py +++ b/bot/cogs/filter_lists.py @@ -88,10 +88,7 @@ class FilterLists(Cog): # Find the content and delete it. log.trace(f"Trying to delete the {content} item from the {list_type} {allow_type}") - for allow_list, metadata in self.bot.filter_list_cache[f"{list_type}.{allowed}"].items(): - if content == allow_list: - item = metadata - break + item = self.bot.filter_list_cache[f"{list_type}.{allowed}"].get(content) if item is not None: await self.bot.api_client.delete( -- cgit v1.2.3 From 4d1099938f4582330ce6c732dac4862df6ec68e4 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 21:54:27 +0200 Subject: Make sure file formats have leading dots. --- bot/cogs/filter_lists.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py index f133d53d9..8831a2143 100644 --- a/bot/cogs/filter_lists.py +++ b/bot/cogs/filter_lists.py @@ -43,6 +43,10 @@ class FilterLists(Cog): if not comment: comment = guild_data.get("name") + # If it's a file format, let's make sure it has a leading dot. + elif list_type == "FILE_FORMAT" and not content.startswith("."): + content = f".{content}" + # Try to add the item to the database log.trace(f"Trying to add the {content} item to the {list_type} {allow_type}") payload = { @@ -86,6 +90,10 @@ class FilterLists(Cog): guild_data = await self._validate_guild_invite(ctx, content) content = guild_data.get("id") + # If it's a file format, let's make sure it has a leading dot. + elif list_type == "FILE_FORMAT" and not content.startswith("."): + content = f".{content}" + # Find the content and delete it. log.trace(f"Trying to delete the {content} item from the {list_type} {allow_type}") item = self.bot.filter_list_cache[f"{list_type}.{allowed}"].get(content) -- cgit v1.2.3 From 0f8a89bd8be9b5bd6fbad989ad3aa57103a1f9da Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 23:46:23 +0200 Subject: Dynamically amend types to filterlist docstrings. We want the !help invocations to give you all the information you need in order to use the command. That also means we need to provide the valid filterlist types, which are subject to change. So, we fetch the valid ones from the API and then dynamically insert them into the docstrings. --- bot/cogs/filter_lists.py | 24 ++++++++++++++++++++++++ bot/converters.py | 19 ++++++++++++++----- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py index 8831a2143..fbd070bb9 100644 --- a/bot/cogs/filter_lists.py +++ b/bot/cogs/filter_lists.py @@ -17,8 +17,32 @@ log = logging.getLogger(__name__) class FilterLists(Cog): """Commands for blacklisting and whitelisting things.""" + methods_with_filterlist_types = [ + "allow_add", + "allow_delete", + "allow_get", + "deny_add", + "deny_delete", + "deny_get", + ] + def __init__(self, bot: Bot) -> None: self.bot = bot + self.bot.loop.create_task(self._amend_docstrings()) + + async def _amend_docstrings(self) -> None: + """Add the valid FilterList types to the docstrings, so they'll appear in !help invocations.""" + await self.bot.wait_until_guild_available() + + # Add valid filterlist types to the docstrings + valid_types = await ValidFilterListType.get_valid_types(self.bot) + valid_types = [f"`{type_.lower()}`" for type_ in valid_types] + + for method_name in self.methods_with_filterlist_types: + command = getattr(self, method_name) + command.help = ( + f"{command.help}\n\nValid **list_type** values are {', '.join(valid_types)}." + ) async def _add_data( self, diff --git a/bot/converters.py b/bot/converters.py index 5912e3e61..c9f525dd1 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -9,7 +9,7 @@ import dateutil.tz import discord from aiohttp import ClientConnectorError from dateutil.relativedelta import relativedelta -from discord.ext.commands import BadArgument, Context, Converter, IDConverter, UserConverter +from discord.ext.commands import BadArgument, Bot, Context, Converter, IDConverter, UserConverter from bot.api import ResponseCodeError from bot.constants import URLs @@ -81,14 +81,23 @@ class ValidFilterListType(Converter): passes through the given argument otherwise. """ - async def convert(self, ctx: Context, list_type: str) -> str: - """Checks whether the given string is a valid FilterList type.""" + @staticmethod + async def get_valid_types(bot: Bot) -> list: + """ + Try to get a list of valid filter list types. + + Raise a BadArgument if the API can't respond. + """ try: - valid_types = await ctx.bot.api_client.get('bot/filter-lists/get-types') + valid_types = await bot.api_client.get('bot/filter-lists/get-types') except ResponseCodeError: raise BadArgument("Cannot validate list_type: Unable to fetch valid types from API.") - valid_types = [enum for enum, classname in valid_types] + return [enum for enum, classname in valid_types] + + async def convert(self, ctx: Context, list_type: str) -> str: + """Checks whether the given string is a valid FilterList type.""" + valid_types = await self.get_valid_types(ctx.bot) list_type = list_type.upper() if list_type not in valid_types: -- cgit v1.2.3 From 9795d680b50a704424959d581d1f137b28f4e859 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Thu, 30 Jul 2020 00:10:33 +0200 Subject: Add more explicit feedback to failures. For deleting and listing data, we now get some more feedback when things fail. --- bot/cogs/filter_lists.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py index fbd070bb9..52db1fcb5 100644 --- a/bot/cogs/filter_lists.py +++ b/bot/cogs/filter_lists.py @@ -106,7 +106,6 @@ class FilterLists(Cog): async def _delete_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType, content: str) -> None: """Remove an item from a filterlist.""" - item = None allow_type = "whitelist" if allowed else "blacklist" # If this is a server invite, we need to convert it. @@ -123,11 +122,20 @@ class FilterLists(Cog): item = self.bot.filter_list_cache[f"{list_type}.{allowed}"].get(content) if item is not None: - await self.bot.api_client.delete( - f"bot/filter-lists/{item['id']}" - ) - del self.bot.filter_list_cache[f"{list_type}.{allowed}"][content] - await ctx.message.add_reaction("✅") + try: + await self.bot.api_client.delete( + f"bot/filter-lists/{item['id']}" + ) + del self.bot.filter_list_cache[f"{list_type}.{allowed}"][content] + await ctx.message.add_reaction("✅") + except ResponseCodeError as e: + log.debug( + f"{ctx.author} tried to delete an item with the id {item['id']}, but " + f"the API raised an unexpected error: {e}" + ) + await ctx.message.add_reaction("❌") + else: + await ctx.message.add_reaction("❌") async def _list_all_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType) -> None: """Paginate and display all items in a filterlist.""" @@ -158,8 +166,10 @@ class FilterLists(Cog): else: embed.description = "Hmmm, seems like there's nothing here yet." await ctx.send(embed=embed) + await ctx.message.add_reaction("❌") - async def _validate_guild_invite(self, ctx: Context, invite: str) -> dict: + @staticmethod + async def _validate_guild_invite(ctx: Context, invite: str) -> dict: """ Validates a guild invite, and returns the guild info as a dict. -- cgit v1.2.3 From f97defcab304e6f2e3175f10e9888db30a0be0c8 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Thu, 30 Jul 2020 13:54:55 +0200 Subject: Fix channel moving incase `message.pin` fails --- bot/cogs/help_channels.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 1f87c3e39..5d4346000 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -701,6 +701,8 @@ class HelpChannels(commands.Cog): await message.pin() except discord.NotFound: log.info(f"Pinning message {message.id} ({channel}) failed because message got deleted.") + except discord.HTTPException as e: + log.info(f"Pinning message {message.id} ({channel.id}) failed with code {e.code}", exc_info=e) else: await self.question_messages.set(channel.id, message.id) -- cgit v1.2.3 From 873ecb9e11e72b1d62aa288660e0e582038d365e Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Thu, 30 Jul 2020 18:49:26 +0200 Subject: Change regex so it catches new discord URL --- bot/cogs/webhook_remover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index 543869215..91bcaa1e9 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -8,7 +8,7 @@ from bot.bot import Bot from bot.cogs.moderation.modlog import ModLog from bot.constants import Channels, Colours, Event, Icons -WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discordapp\.com/api/webhooks/\d+/)\S+/?", re.I) +WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(app)?\.com/api/webhooks/\d+/)\S+/?", re.I) ALERT_MESSAGE_TEMPLATE = ( "{user}, looks like you posted a Discord webhook URL. Therefore, your " -- cgit v1.2.3 From 663eac8f9f30812f6ee6c95b134e5e62fa7273d6 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Thu, 30 Jul 2020 19:03:33 +0200 Subject: Use non-capturing group instead. --- bot/cogs/webhook_remover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index 91bcaa1e9..1ed8072f2 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -8,7 +8,7 @@ from bot.bot import Bot from bot.cogs.moderation.modlog import ModLog from bot.constants import Channels, Colours, Event, Icons -WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(app)?\.com/api/webhooks/\d+/)\S+/?", re.I) +WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)\.com/api/webhooks/\d+/)\S+/?", re.I) ALERT_MESSAGE_TEMPLATE = ( "{user}, looks like you posted a Discord webhook URL. Therefore, your " -- cgit v1.2.3 From 3586c1e187ecbee957084b2e2e6fbcf9bc2e2859 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Thu, 30 Jul 2020 19:05:28 +0200 Subject: Missed `?` in regex. --- bot/cogs/webhook_remover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index 1ed8072f2..ac9f7c20f 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -8,7 +8,7 @@ from bot.bot import Bot from bot.cogs.moderation.modlog import ModLog from bot.constants import Channels, Colours, Event, Icons -WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)\.com/api/webhooks/\d+/)\S+/?", re.I) +WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\d+/)\S+/?", re.I) ALERT_MESSAGE_TEMPLATE = ( "{user}, looks like you posted a Discord webhook URL. Therefore, your " -- cgit v1.2.3 From 6ee96040febd8ba1f0b1f781ca996c1097f4b87c Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Thu, 30 Jul 2020 19:10:18 +0200 Subject: Use full flag name for case-insensitivity requested by lemon --- bot/cogs/webhook_remover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index ac9f7c20f..5812da87c 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -8,7 +8,7 @@ from bot.bot import Bot from bot.cogs.moderation.modlog import ModLog from bot.constants import Channels, Colours, Event, Icons -WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\d+/)\S+/?", re.I) +WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\d+/)\S+/?", re.IGNORECASE) ALERT_MESSAGE_TEMPLATE = ( "{user}, looks like you posted a Discord webhook URL. Therefore, your " -- cgit v1.2.3 From da484b1b22a0d346a1dbc1abf2ffe1027a7e5031 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Fri, 31 Jul 2020 19:52:01 +0200 Subject: Remove superfluous Available help channels. This adds a little bit of logic to the Help Channel `init_available` coroutine, which runs when the cog loads. This ensures that if there are more help channels in available than there should be, we remove the superfluos ones. Previously, if the bot started with too many channels, it would maintain and defend that excessive amount. This is because we never actually count the number of channels before adding in new available channels whenever one disappears. If we ever get too many available channels in the future, this can be solved by simply reloading this cog. --- bot/cogs/help_channels.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 5d4346000..1be980472 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -364,10 +364,18 @@ class HelpChannels(commands.Cog): channels = list(self.get_category_channels(self.available_category)) missing = constants.HelpChannels.max_available - len(channels) - log.trace(f"Moving {missing} missing channels to the Available category.") - - for _ in range(missing): - await self.move_to_available() + # If we've got less than `max_available` channel available, we should add some. + if missing > 0: + log.trace(f"Moving {missing} missing channels to the Available category.") + for _ in range(missing): + await self.move_to_available() + + # If for some reason we have more than `max_available` channels available, + # we should move the superfluous ones over to dormant. + elif missing < 0: + log.trace(f"Moving {abs(missing)} superfluous available channels over to the Dormant category.") + for channel in channels[:abs(missing)]: + await self.move_to_dormant(channel, "auto") async def init_categories(self) -> None: """Get the help category objects. Remove the cog if retrieval fails.""" -- cgit v1.2.3 From ef24f6dcce1ed527f8561c4bfa41f390bde692bc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 18 Mar 2020 18:58:56 -0700 Subject: Reminders: remove duplicate deletion in scheduled task `send_reminder` already deletes the reminder so it's redundant to delete it in the scheduled task too. --- bot/cogs/reminders.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index b5998cc0e..cbc7d6920 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -144,16 +144,8 @@ class Reminders(Cog): def schedule_reminder(self, reminder: dict) -> None: """A coroutine which sends the reminder once the time is reached, and cancels the running task.""" - reminder_id = reminder["id"] reminder_datetime = isoparse(reminder['expiration']).replace(tzinfo=None) - - async def _remind() -> None: - await self.send_reminder(reminder) - - log.debug(f"Deleting reminder {reminder_id} (the user has been reminded).") - await self._delete_reminder(reminder_id) - - self.scheduler.schedule_at(reminder_datetime, reminder_id, _remind()) + self.scheduler.schedule_at(reminder_datetime, reminder["id"], self.send_reminder(reminder)) async def _delete_reminder(self, reminder_id: str, cancel_task: bool = True) -> None: """Delete a reminder from the database, given its ID, and cancel the running task.""" -- cgit v1.2.3 From 96729f9979a0e3db8ba86b86bb25026c212c35dc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 18 Mar 2020 19:10:46 -0700 Subject: Reminders: remove _delete_reminder function Only one call was benefiting from that function also cancelling the task. Therefore, the function was redundant and has been replaced with a direct request to delete. This change has the consequence of also fixing reminder tasks cancelling themselves. That issue was potentially suppressing errors (such as the duplicate DELETE request which was fixed earlier). Under normal circumstances, the scheduler will automatically removed finished tasks so tasks won't need to cancel/remove themselves. --- bot/cogs/reminders.py | 32 +++++++++++--------------------- 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index cbc7d6920..14fe43efb 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -48,7 +48,7 @@ class Reminders(Cog): now = datetime.utcnow() for reminder in response: - is_valid, *_ = self.ensure_valid_reminder(reminder, cancel_task=False) + is_valid, *_ = self.ensure_valid_reminder(reminder) if not is_valid: continue @@ -61,11 +61,7 @@ class Reminders(Cog): else: self.schedule_reminder(reminder) - def ensure_valid_reminder( - self, - reminder: dict, - cancel_task: bool = True - ) -> t.Tuple[bool, discord.User, discord.TextChannel]: + def ensure_valid_reminder(self, reminder: dict) -> t.Tuple[bool, discord.User, discord.TextChannel]: """Ensure reminder author and channel can be fetched otherwise delete the reminder.""" user = self.bot.get_user(reminder['author']) channel = self.bot.get_channel(reminder['channel_id']) @@ -76,7 +72,7 @@ class Reminders(Cog): f"Reminder {reminder['id']} invalid: " f"User {reminder['author']}={user}, Channel {reminder['channel_id']}={channel}." ) - asyncio.create_task(self._delete_reminder(reminder['id'], cancel_task)) + asyncio.create_task(self.bot.api_client.delete(f"bot/reminders/{reminder['id']}")) return is_valid, user, channel @@ -147,14 +143,6 @@ class Reminders(Cog): reminder_datetime = isoparse(reminder['expiration']).replace(tzinfo=None) self.scheduler.schedule_at(reminder_datetime, reminder["id"], self.send_reminder(reminder)) - async def _delete_reminder(self, reminder_id: str, cancel_task: bool = True) -> None: - """Delete a reminder from the database, given its ID, and cancel the running task.""" - await self.bot.api_client.delete('bot/reminders/' + str(reminder_id)) - - if cancel_task: - # Now we can remove it from the schedule list - self.scheduler.cancel(reminder_id) - async def _edit_reminder(self, reminder_id: int, payload: dict) -> dict: """ Edits a reminder in the database given the ID and payload. @@ -180,6 +168,7 @@ class Reminders(Cog): """Send the reminder.""" is_valid, user, channel = self.ensure_valid_reminder(reminder) if not is_valid: + # No need to cancel the task too; it'll simply be done once this coroutine returns. return embed = discord.Embed() @@ -205,11 +194,10 @@ class Reminders(Cog): mentionable.mention for mentionable in self.get_mentionables(reminder["mentions"]) ) - await channel.send( - content=f"{user.mention} {additional_mentions}", - embed=embed - ) - await self._delete_reminder(reminder["id"]) + await channel.send(content=f"{user.mention} {additional_mentions}", embed=embed) + + log.debug(f"Deleting reminder #{reminder['id']} (the user has been reminded).") + await self.bot.api_client.delete(f"bot/reminders/{reminder['id']}") @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True) async def remind_group( @@ -401,7 +389,9 @@ class Reminders(Cog): @remind_group.command("delete", aliases=("remove", "cancel")) async def delete_reminder(self, ctx: Context, id_: int) -> None: """Delete one of your active reminders.""" - await self._delete_reminder(id_) + await self.bot.api_client.delete(f"bot/reminders/{id_}") + self.scheduler.cancel(id_) + await self._send_confirmation( ctx, on_success="That reminder has been deleted successfully!", -- cgit v1.2.3 From 4d5dedc492c070b4fe98d630026ef7e0504ff5a8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 12 Jul 2020 18:26:30 -0700 Subject: Reminders: fix reminder_id type annotation It's fine to accept an int since it'll get converted to a string anyway. --- bot/cogs/reminders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 14fe43efb..a043b7d75 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -80,7 +80,7 @@ class Reminders(Cog): async def _send_confirmation( ctx: Context, on_success: str, - reminder_id: str, + reminder_id: t.Union[str, int], delivery_dt: t.Optional[datetime], ) -> None: """Send an embed confirming the reminder change was made successfully.""" -- cgit v1.2.3 From b3e1ebfb7a8d9a31bbd5eba1a14c1c132590ee86 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 10:01:56 -0700 Subject: Decorators: more accurate return type for checks --- bot/decorators.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index 500197c89..b9182f664 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -24,7 +24,7 @@ def in_whitelist( roles: Container[int] = (), redirect: Optional[int] = Channels.bot_commands, fail_silently: bool = False, -) -> Callable: +) -> commands.Command: """ Check if a command was issued in a whitelisted context. @@ -45,7 +45,7 @@ def in_whitelist( return commands.check(predicate) -def with_role(*role_ids: int) -> Callable: +def with_role(*role_ids: int) -> commands.Command: """Returns True if the user has any one of the roles in role_ids.""" async def predicate(ctx: Context) -> bool: """With role checker predicate.""" @@ -53,7 +53,7 @@ def with_role(*role_ids: int) -> Callable: return commands.check(predicate) -def without_role(*role_ids: int) -> Callable: +def without_role(*role_ids: int) -> commands.Command: """Returns True if the user does not have any of the roles in role_ids.""" async def predicate(ctx: Context) -> bool: return without_role_check(ctx, *role_ids) -- cgit v1.2.3 From da33c330a02f2ff10838d0827e8c26a045729449 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 10:08:05 -0700 Subject: Decorators: clean up imports --- bot/decorators.py | 50 +++++++++++++++++++++----------------------- tests/bot/test_decorators.py | 4 ++-- 2 files changed, 26 insertions(+), 28 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index b9182f664..d9e5e3a83 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -1,15 +1,13 @@ +import asyncio import logging import random -from asyncio import Lock, create_task, sleep +import typing as t from contextlib import suppress from functools import wraps -from typing import Callable, Container, Optional, Union from weakref import WeakValueDictionary -from discord import Colour, Embed, Member -from discord.errors import NotFound -from discord.ext import commands -from discord.ext.commands import Cog, Context +from discord import Colour, Embed, Member, NotFound +from discord.ext.commands import Cog, Command, Context, check from bot.constants import Channels, ERROR_REPLIES, RedirectOutput from bot.utils.checks import in_whitelist_check, with_role_check, without_role_check @@ -19,12 +17,12 @@ log = logging.getLogger(__name__) def in_whitelist( *, - channels: Container[int] = (), - categories: Container[int] = (), - roles: Container[int] = (), - redirect: Optional[int] = Channels.bot_commands, + channels: t.Container[int] = (), + categories: t.Container[int] = (), + roles: t.Container[int] = (), + redirect: t.Optional[int] = Channels.bot_commands, fail_silently: bool = False, -) -> commands.Command: +) -> Command: """ Check if a command was issued in a whitelisted context. @@ -42,25 +40,25 @@ def in_whitelist( """Check if command was issued in a whitelisted context.""" return in_whitelist_check(ctx, channels, categories, roles, redirect, fail_silently) - return commands.check(predicate) + return check(predicate) -def with_role(*role_ids: int) -> commands.Command: +def with_role(*role_ids: int) -> Command: """Returns True if the user has any one of the roles in role_ids.""" async def predicate(ctx: Context) -> bool: """With role checker predicate.""" return with_role_check(ctx, *role_ids) - return commands.check(predicate) + return check(predicate) -def without_role(*role_ids: int) -> commands.Command: +def without_role(*role_ids: int) -> Command: """Returns True if the user does not have any of the roles in role_ids.""" async def predicate(ctx: Context) -> bool: return without_role_check(ctx, *role_ids) - return commands.check(predicate) + return check(predicate) -def locked() -> Callable: +def locked() -> t.Callable: """ Allows the user to only run one instance of the decorated command at a time. @@ -68,12 +66,12 @@ def locked() -> Callable: This decorator must go before (below) the `command` decorator. """ - def wrap(func: Callable) -> Callable: + def wrap(func: t.Callable) -> t.Callable: func.__locks = WeakValueDictionary() @wraps(func) async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None: - lock = func.__locks.setdefault(ctx.author.id, Lock()) + lock = func.__locks.setdefault(ctx.author.id, asyncio.Lock()) if lock.locked(): embed = Embed() embed.colour = Colour.red() @@ -86,13 +84,13 @@ def locked() -> Callable: await ctx.send(embed=embed) return - async with func.__locks.setdefault(ctx.author.id, Lock()): + async with func.__locks.setdefault(ctx.author.id, asyncio.Lock()): await func(self, ctx, *args, **kwargs) return inner return wrap -def redirect_output(destination_channel: int, bypass_roles: Container[int] = None) -> Callable: +def redirect_output(destination_channel: int, bypass_roles: t.Container[int] = None) -> t.Callable: """ Changes the channel in the context of the command to redirect the output to a certain channel. @@ -100,7 +98,7 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non This decorator must go before (below) the `command` decorator. """ - def wrap(func: Callable) -> Callable: + def wrap(func: t.Callable) -> t.Callable: @wraps(func) async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None: if ctx.channel.id == destination_channel: @@ -119,14 +117,14 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non log.trace(f"Redirecting output of {ctx.author}'s command '{ctx.command.name}' to {redirect_channel.name}") ctx.channel = redirect_channel await ctx.channel.send(f"Here's the output of your command, {ctx.author.mention}") - create_task(func(self, ctx, *args, **kwargs)) + asyncio.create_task(func(self, ctx, *args, **kwargs)) message = await old_channel.send( f"Hey, {ctx.author.mention}, you can find the output of your command here: " f"{redirect_channel.mention}" ) if RedirectOutput.delete_invocation: - await sleep(RedirectOutput.delete_delay) + await asyncio.sleep(RedirectOutput.delete_delay) with suppress(NotFound): await message.delete() @@ -140,7 +138,7 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non return wrap -def respect_role_hierarchy(target_arg: Union[int, str] = 0) -> Callable: +def respect_role_hierarchy(target_arg: t.Union[int, str] = 0) -> t.Callable: """ Ensure the highest role of the invoking member is greater than that of the target member. @@ -152,7 +150,7 @@ def respect_role_hierarchy(target_arg: Union[int, str] = 0) -> Callable: This decorator must go before (below) the `command` decorator. """ - def wrap(func: Callable) -> Callable: + def wrap(func: t.Callable) -> t.Callable: @wraps(func) async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None: try: diff --git a/tests/bot/test_decorators.py b/tests/bot/test_decorators.py index 3d450caa0..22e93c1c4 100644 --- a/tests/bot/test_decorators.py +++ b/tests/bot/test_decorators.py @@ -67,7 +67,7 @@ class InWhitelistTests(unittest.TestCase): for test_case in test_cases: # patch `commands.check` with a no-op lambda that just returns the predicate passed to it # so we can test the predicate that was generated from the specified kwargs. - with unittest.mock.patch("bot.decorators.commands.check", new=lambda predicate: predicate): + with unittest.mock.patch("bot.decorators.check", new=lambda predicate: predicate): predicate = in_whitelist(**test_case.kwargs) with self.subTest(test_description=test_case.description): @@ -139,7 +139,7 @@ class InWhitelistTests(unittest.TestCase): # patch `commands.check` with a no-op lambda that just returns the predicate passed to it # so we can test the predicate that was generated from the specified kwargs. - with unittest.mock.patch("bot.decorators.commands.check", new=lambda predicate: predicate): + with unittest.mock.patch("bot.decorators.check", new=lambda predicate: predicate): predicate = in_whitelist(**test_case.kwargs) with self.subTest(test_description=test_case.description): -- cgit v1.2.3 From 5bc3c1e9732955020d8a5f92a8c1952bca3dae0c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 10:13:52 -0700 Subject: Decorators: create helper function to get arg value --- bot/decorators.py | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index d9e5e3a83..1fe082b6e 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -14,6 +14,8 @@ from bot.utils.checks import in_whitelist_check, with_role_check, without_role_c log = logging.getLogger(__name__) +Argument = t.Union[int, str] + def in_whitelist( *, @@ -138,7 +140,7 @@ def redirect_output(destination_channel: int, bypass_roles: t.Container[int] = N return wrap -def respect_role_hierarchy(target_arg: t.Union[int, str] = 0) -> t.Callable: +def respect_role_hierarchy(target_arg: Argument = 0) -> t.Callable: """ Ensure the highest role of the invoking member is greater than that of the target member. @@ -153,15 +155,7 @@ def respect_role_hierarchy(target_arg: t.Union[int, str] = 0) -> t.Callable: def wrap(func: t.Callable) -> t.Callable: @wraps(func) async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None: - try: - target = kwargs[target_arg] - except KeyError: - try: - target = args[target_arg] - except IndexError: - raise ValueError(f"Could not find target argument at position {target_arg}") - except TypeError: - raise ValueError(f"Could not find target kwarg with key {target_arg!r}") + target = _get_arg_value(target_arg, args, kwargs) if not isinstance(target, Member): log.trace("The target is not a discord.Member; skipping role hierarchy check.") @@ -183,3 +177,23 @@ def respect_role_hierarchy(target_arg: t.Union[int, str] = 0) -> t.Callable: await func(self, ctx, *args, **kwargs) return inner return wrap + + +def _get_arg_value(target_arg: Argument, args: t.Tuple, kwargs: t.Dict[str, t.Any]) -> t.Any: + """ + Return the value of the arg at the given position or name `target_arg`. + + Use an integer as a position if the target argument is positional. + Use a string as a parameter name if the target argument is a keyword argument. + + Raise ValueError if `target_arg` cannot be found. + """ + try: + return kwargs[target_arg] + except KeyError: + try: + return args[target_arg] + except IndexError: + raise ValueError(f"Could not find target argument at position {target_arg}") + except TypeError: + raise ValueError(f"Could not find target kwarg with key {target_arg!r}") -- cgit v1.2.3 From be93601a31dcfa8acb03996eaaf2edcb654712f5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 10:33:21 -0700 Subject: Decorators: add mutually exclusive decorator This will be used to prevent race conditions on a resource by stopping all other access to the resource once its been acquired. --- bot/decorators.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/bot/decorators.py b/bot/decorators.py index 1fe082b6e..cae0870b6 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -2,6 +2,7 @@ import asyncio import logging import random import typing as t +from collections import defaultdict from contextlib import suppress from functools import wraps from weakref import WeakValueDictionary @@ -13,8 +14,10 @@ from bot.constants import Channels, ERROR_REPLIES, RedirectOutput from bot.utils.checks import in_whitelist_check, with_role_check, without_role_check log = logging.getLogger(__name__) +__lock_dicts = defaultdict(WeakValueDictionary) Argument = t.Union[int, str] +ResourceId = t.Union[Argument, t.Callable[..., t.Hashable]] def in_whitelist( @@ -92,6 +95,42 @@ def locked() -> t.Callable: return wrap +def mutually_exclusive(namespace: t.Hashable, resource_arg: ResourceId) -> t.Callable: + """ + Turn the decorated coroutine function into a mutually exclusive operation on a resource. + + If any other mutually exclusive function currently holds the lock for a resource, do not run the + decorated function and return None. + + `namespace` is an identifier used to prevent collisions among resource IDs. + + `resource_arg` is the positional index or name of the parameter of the decorated function whose + value will be the resource ID. It may also be a callable which will return the resource ID + given the decorated function's args and kwargs. + """ + def decorator(func: t.Callable) -> t.Callable: + @wraps(func) + async def wrapper(*args, **kwargs) -> t.Any: + if callable(resource_arg): + # Call to get the ID if a callable was given. + id_ = resource_arg(*args, **kwargs) + else: + # Retrieve the ID from the args via position or name. + id_ = _get_arg_value(resource_arg, args, kwargs) + + # Get the lock for the ID. Create a Lock if one doesn't exist yet. + locks = __lock_dicts[namespace] + lock = locks.setdefault(id_, asyncio.Lock) + + if not lock.locked(): + # Resource is free; acquire it. + async with lock: + return await func(*args, **kwargs) + + return wrapper + return decorator + + def redirect_output(destination_channel: int, bypass_roles: t.Container[int] = None) -> t.Callable: """ Changes the channel in the context of the command to redirect the output to a certain channel. -- cgit v1.2.3 From a16ba995944e213f90fbe78d2ec534f99f70b9f8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 11:06:17 -0700 Subject: Decorators: drop arg pos/name support for mutually_exclusive Supporting ID retrieval by arg name or position made for a confusing interface. I also doubt it would have been used much. A callable can achieve the same thing, albeit with a little more code. Now the decorator instead supports passing an ID directly or a callable. --- bot/decorators.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index cae0870b6..f49499856 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -17,7 +17,7 @@ log = logging.getLogger(__name__) __lock_dicts = defaultdict(WeakValueDictionary) Argument = t.Union[int, str] -ResourceId = t.Union[Argument, t.Callable[..., t.Hashable]] +ResourceId = t.Union[t.Hashable, t.Callable[..., t.Hashable]] def in_whitelist( @@ -95,28 +95,27 @@ def locked() -> t.Callable: return wrap -def mutually_exclusive(namespace: t.Hashable, resource_arg: ResourceId) -> t.Callable: +def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Callable: """ - Turn the decorated coroutine function into a mutually exclusive operation on a resource. + Turn the decorated coroutine function into a mutually exclusive operation on a `resource_id`. If any other mutually exclusive function currently holds the lock for a resource, do not run the decorated function and return None. `namespace` is an identifier used to prevent collisions among resource IDs. - `resource_arg` is the positional index or name of the parameter of the decorated function whose - value will be the resource ID. It may also be a callable which will return the resource ID - given the decorated function's args and kwargs. + `resource_id` identifies a resource on which to perform a mutually exclusive operation. It may + also be a callable which will return the resource ID given the decorated function's args and + kwargs. """ def decorator(func: t.Callable) -> t.Callable: @wraps(func) async def wrapper(*args, **kwargs) -> t.Any: - if callable(resource_arg): + if callable(resource_id): # Call to get the ID if a callable was given. - id_ = resource_arg(*args, **kwargs) + id_ = resource_id(*args, **kwargs) else: - # Retrieve the ID from the args via position or name. - id_ = _get_arg_value(resource_arg, args, kwargs) + id_ = resource_id # Get the lock for the ID. Create a Lock if one doesn't exist yet. locks = __lock_dicts[namespace] -- cgit v1.2.3 From 226d5e17b74d711776f7e7f49a8712ba820ac5ba Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 11:22:00 -0700 Subject: Decorators: support awaitables for resource ID --- bot/decorators.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index f49499856..063368dda 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -1,4 +1,5 @@ import asyncio +import inspect import logging import random import typing as t @@ -17,7 +18,9 @@ log = logging.getLogger(__name__) __lock_dicts = defaultdict(WeakValueDictionary) Argument = t.Union[int, str] -ResourceId = t.Union[t.Hashable, t.Callable[..., t.Hashable]] +_IdCallable = t.Callable[..., t.Hashable] +_IdAwaitable = t.Callable[..., t.Awaitable[t.Hashable]] +ResourceId = t.Union[t.Hashable, _IdCallable, _IdAwaitable] def in_whitelist( @@ -104,9 +107,9 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call `namespace` is an identifier used to prevent collisions among resource IDs. - `resource_id` identifies a resource on which to perform a mutually exclusive operation. It may - also be a callable which will return the resource ID given the decorated function's args and - kwargs. + `resource_id` identifies a resource on which to perform a mutually exclusive operation. + It may also be a callable or awaitable which will return the resource ID given the decorated + function's args and kwargs. """ def decorator(func: t.Callable) -> t.Callable: @wraps(func) @@ -114,6 +117,10 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call if callable(resource_id): # Call to get the ID if a callable was given. id_ = resource_id(*args, **kwargs) + + if inspect.isawaitable(id_): + # Await to get the ID if an awaitable was given. + id_ = await id_ else: id_ = resource_id -- cgit v1.2.3 From 66ca3a8313183bcb245804d68a6f09abd2724245 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 12:04:45 -0700 Subject: Decorators: fix lock creation --- bot/decorators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/decorators.py b/bot/decorators.py index 063368dda..abf7474ef 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -126,7 +126,7 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call # Get the lock for the ID. Create a Lock if one doesn't exist yet. locks = __lock_dicts[namespace] - lock = locks.setdefault(id_, asyncio.Lock) + lock = locks.setdefault(id_, asyncio.Lock()) if not lock.locked(): # Resource is free; acquire it. -- cgit v1.2.3 From 6e2d3dfaacf03435c18e843fb366758f49e09181 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 12:23:31 -0700 Subject: Decorators: add logging for mutually_exclusive --- bot/decorators.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index abf7474ef..91104fc6c 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -112,26 +112,34 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call function's args and kwargs. """ def decorator(func: t.Callable) -> t.Callable: + name = func.__name__ + @wraps(func) async def wrapper(*args, **kwargs) -> t.Any: + log.trace(f"{name}: mutually exclusive decorator called") + if callable(resource_id): - # Call to get the ID if a callable was given. + log.trace(f"{name}: calling the given callable to get the resource ID") id_ = resource_id(*args, **kwargs) if inspect.isawaitable(id_): - # Await to get the ID if an awaitable was given. + log.trace(f"{name}: awaiting to get resource ID") id_ = await id_ else: id_ = resource_id - # Get the lock for the ID. Create a Lock if one doesn't exist yet. + log.trace(f"{name}: getting lock for resource {id_!r} under namespace {namespace!r}") + + # Get the lock for the ID. Create a lock if one doesn't exist yet. locks = __lock_dicts[namespace] lock = locks.setdefault(id_, asyncio.Lock()) if not lock.locked(): - # Resource is free; acquire it. + log.debug(f"{name}: resource {namespace!r}:{id_!r} is free; acquiring it...") async with lock: return await func(*args, **kwargs) + else: + log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked") return wrapper return decorator -- cgit v1.2.3 From 0c03950cd4a4d1c8734e2a7172344dd073d3d188 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 13:22:40 -0700 Subject: Decorators: clarify use of mutually_exclusive with commands --- bot/decorators.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/decorators.py b/bot/decorators.py index 91104fc6c..f581e66d2 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -110,6 +110,8 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call `resource_id` identifies a resource on which to perform a mutually exclusive operation. It may also be a callable or awaitable which will return the resource ID given the decorated function's args and kwargs. + + If decorating a command, this decorator must go before (below) the `command` decorator. """ def decorator(func: t.Callable) -> t.Callable: name = func.__name__ -- cgit v1.2.3 From a30b640a61943c1992b8614b98667d49653d8b71 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 23:12:07 -0700 Subject: Decorators: pass bound arguments to callable Bound arguments are more convenient to work with than the raw args and kwargs. --- bot/decorators.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index f581e66d2..7f58abd1c 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -18,9 +18,10 @@ log = logging.getLogger(__name__) __lock_dicts = defaultdict(WeakValueDictionary) Argument = t.Union[int, str] -_IdCallable = t.Callable[..., t.Hashable] -_IdAwaitable = t.Callable[..., t.Awaitable[t.Hashable]] -ResourceId = t.Union[t.Hashable, _IdCallable, _IdAwaitable] +BoundArgs = t.OrderedDict[str, t.Any] +_IdCallableReturn = t.Union[t.Hashable, t.Awaitable[t.Hashable]] +_IdCallable = t.Callable[[BoundArgs], _IdCallableReturn] +ResourceId = t.Union[t.Hashable, _IdCallable] def in_whitelist( @@ -108,8 +109,8 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call `namespace` is an identifier used to prevent collisions among resource IDs. `resource_id` identifies a resource on which to perform a mutually exclusive operation. - It may also be a callable or awaitable which will return the resource ID given the decorated - function's args and kwargs. + It may also be a callable or awaitable which will return the resource ID given an ordered + mapping of the parameters' names to arguments' values. If decorating a command, this decorator must go before (below) the `command` decorator. """ @@ -121,8 +122,13 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call log.trace(f"{name}: mutually exclusive decorator called") if callable(resource_id): + log.trace(f"{name}: binding args to signature") + sig = inspect.signature(func) + bound_args = sig.bind(*args, **kwargs) + bound_args.apply_defaults() + log.trace(f"{name}: calling the given callable to get the resource ID") - id_ = resource_id(*args, **kwargs) + id_ = resource_id(bound_args.arguments) if inspect.isawaitable(id_): log.trace(f"{name}: awaiting to get resource ID") -- cgit v1.2.3 From dc2c1c7f44de99ad9cbc69edc90607c625562760 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 23:53:17 -0700 Subject: Add util function to get value from arg This is a more advanced version meant to eventually replace the `_get_arg_values` in decorators.py. --- bot/utils/function.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 bot/utils/function.py diff --git a/bot/utils/function.py b/bot/utils/function.py new file mode 100644 index 000000000..7c5949122 --- /dev/null +++ b/bot/utils/function.py @@ -0,0 +1,34 @@ +"""Utilities for interaction with functions.""" + +import typing as t + +Argument = t.Union[int, str] + + +def get_arg_value(name_or_pos: Argument, arguments: t.OrderedDict[str, t.Any]) -> t.Any: + """ + Return a value from `arguments` based on a name or position. + + `arguments` is an ordered mapping of parameter names to argument values. + + Raise TypeError if `name_or_pos` isn't a str or int. + Raise ValueError if `name_or_pos` does not match any argument. + """ + if isinstance(name_or_pos, int): + # Convert arguments to a tuple to make them indexable. + arg_values = tuple(arguments.items()) + arg_pos = name_or_pos + + try: + name, value = arg_values[arg_pos] + return value + except IndexError: + raise ValueError(f"Argument position {arg_pos} is out of bounds.") + elif isinstance(name_or_pos, str): + arg_name = name_or_pos + try: + return arguments[arg_name] + except KeyError: + raise ValueError(f"Argument {arg_name!r} doesn't exist.") + else: + raise TypeError("'arg' must either be an int (positional index) or a str (keyword).") -- cgit v1.2.3 From 022b43c2651cd568592327e8b493fbefaa4f332a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jul 2020 10:31:35 -0700 Subject: Reminders: make operations mutually exclusive This fixes race conditions between editing, deleting, and sending a reminder. If one operation is already happening, the others will be aborted. --- bot/cogs/reminders.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index a043b7d75..1a0a9d303 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -4,6 +4,7 @@ import random import textwrap import typing as t from datetime import datetime, timedelta +from functools import partial from operator import itemgetter import discord @@ -14,14 +15,17 @@ from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, STAFF_ROLES from bot.converters import Duration +from bot.decorators import mutually_exclusive from bot.pagination import LinePaginator from bot.utils.checks import without_role_check +from bot.utils.function import get_arg_value from bot.utils.messages import send_denial from bot.utils.scheduling import Scheduler from bot.utils.time import humanize_delta log = logging.getLogger(__name__) +NAMESPACE = "reminders" # Used for the mutually_exclusive decorator; constant to prevent typos WHITELISTED_CHANNELS = Guild.reminder_whitelist MAXIMUM_REMINDERS = 5 @@ -164,6 +168,7 @@ class Reminders(Cog): log.trace(f"Scheduling new task #{reminder['id']}") self.schedule_reminder(reminder) + @mutually_exclusive(NAMESPACE, lambda args: get_arg_value("reminder", args)["id"]) async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: """Send the reminder.""" is_valid, user, channel = self.ensure_valid_reminder(reminder) @@ -370,6 +375,7 @@ class Reminders(Cog): mention_ids = [mention.id for mention in mentions] await self.edit_reminder(ctx, id_, {"mentions": mention_ids}) + @mutually_exclusive(NAMESPACE, partial(get_arg_value, "id_")) async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: """Edits a reminder with the given payload, then sends a confirmation message.""" reminder = await self._edit_reminder(id_, payload) @@ -387,6 +393,7 @@ class Reminders(Cog): await self._reschedule_reminder(reminder) @remind_group.command("delete", aliases=("remove", "cancel")) + @mutually_exclusive(NAMESPACE, partial(get_arg_value, "id_")) async def delete_reminder(self, ctx: Context, id_: int) -> None: """Delete one of your active reminders.""" await self.bot.api_client.delete(f"bot/reminders/{id_}") -- cgit v1.2.3 From 8620e5541b89fb42b396dde0dbc5439de103417a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jul 2020 12:29:40 -0700 Subject: Add a function to wrap a decorator to use get_arg_value --- bot/utils/function.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/bot/utils/function.py b/bot/utils/function.py index 7c5949122..23188e79e 100644 --- a/bot/utils/function.py +++ b/bot/utils/function.py @@ -3,9 +3,12 @@ import typing as t Argument = t.Union[int, str] +BoundArgs = t.OrderedDict[str, t.Any] +Decorator = t.Callable[[t.Callable], t.Callable] +ArgValGetter = t.Callable[[BoundArgs], t.Any] -def get_arg_value(name_or_pos: Argument, arguments: t.OrderedDict[str, t.Any]) -> t.Any: +def get_arg_value(name_or_pos: Argument, arguments: BoundArgs) -> t.Any: """ Return a value from `arguments` based on a name or position. @@ -32,3 +35,27 @@ def get_arg_value(name_or_pos: Argument, arguments: t.OrderedDict[str, t.Any]) - raise ValueError(f"Argument {arg_name!r} doesn't exist.") else: raise TypeError("'arg' must either be an int (positional index) or a str (keyword).") + + +def get_arg_value_wrapper( + decorator_func: t.Callable[[ArgValGetter], Decorator], + name_or_pos: Argument, + func: t.Callable[[t.Any], t.Any] = None, +) -> Decorator: + """ + Call `decorator_func` with the value of the arg at the given name/position. + + `decorator_func` must accept a callable as a parameter to which it will pass a mapping of + parameter names to argument values of the function it's decorating. + + `func` is an optional callable which will return a new value given the argument's value. + + Return the decorator returned by `decorator_func`. + """ + def wrapper(args: BoundArgs) -> t.Any: + value = get_arg_value(name_or_pos, args) + if func: + value = func(value) + return value + + return decorator_func(wrapper) -- cgit v1.2.3 From d0e92aeaf9142c80aac949e8a01ff31c40e8ca96 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jul 2020 13:11:21 -0700 Subject: Add a function to get bound args --- bot/decorators.py | 7 +++---- bot/utils/function.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index 7f58abd1c..ef4951141 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -12,6 +12,7 @@ from discord import Colour, Embed, Member, NotFound from discord.ext.commands import Cog, Command, Context, check from bot.constants import Channels, ERROR_REPLIES, RedirectOutput +from bot.utils import function from bot.utils.checks import in_whitelist_check, with_role_check, without_role_check log = logging.getLogger(__name__) @@ -123,12 +124,10 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call if callable(resource_id): log.trace(f"{name}: binding args to signature") - sig = inspect.signature(func) - bound_args = sig.bind(*args, **kwargs) - bound_args.apply_defaults() + bound_args = function.get_bound_args(func, args, kwargs) log.trace(f"{name}: calling the given callable to get the resource ID") - id_ = resource_id(bound_args.arguments) + id_ = resource_id(bound_args) if inspect.isawaitable(id_): log.trace(f"{name}: awaiting to get resource ID") diff --git a/bot/utils/function.py b/bot/utils/function.py index 23188e79e..3ab32fe3c 100644 --- a/bot/utils/function.py +++ b/bot/utils/function.py @@ -1,5 +1,6 @@ """Utilities for interaction with functions.""" +import inspect import typing as t Argument = t.Union[int, str] @@ -59,3 +60,16 @@ def get_arg_value_wrapper( return value return decorator_func(wrapper) + + +def get_bound_args(func: t.Callable, args: t.Tuple, kwargs: t.Dict[str, t.Any]) -> BoundArgs: + """ + Bind `args` and `kwargs` to `func` and return a mapping of parameter names to argument values. + + Default parameter values are also set. + """ + sig = inspect.signature(func) + bound_args = sig.bind(*args, **kwargs) + bound_args.apply_defaults() + + return bound_args.arguments -- cgit v1.2.3 From e0d6138d89112740e1407873a8eb903b9f49ac0a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jul 2020 13:28:34 -0700 Subject: Decorators: use new func utils in respect_role_hierarchy Replace the `_get_arg_value` call with `function.get_arg_value` cause the latter makes use of bound arguments, which are more accurate. --- bot/decorators.py | 43 +++++++++++++------------------------------ 1 file changed, 13 insertions(+), 30 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index ef4951141..b4bf9ba05 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -200,30 +200,33 @@ def redirect_output(destination_channel: int, bypass_roles: t.Container[int] = N return wrap -def respect_role_hierarchy(target_arg: Argument = 0) -> t.Callable: +def respect_role_hierarchy(name_or_pos: Argument = 2) -> t.Callable: """ Ensure the highest role of the invoking member is greater than that of the target member. If the condition fails, a warning is sent to the invoking context. A target which is not an instance of discord.Member will always pass. - A value of 0 (i.e. position 0) for `target_arg` corresponds to the argument which comes after - `ctx`. If the target argument is a kwarg, its name can instead be given. + `name_or_pos` is the keyword name or position index of the parameter of the decorated command + whose value is the target member. This decorator must go before (below) the `command` decorator. """ - def wrap(func: t.Callable) -> t.Callable: + def decorator(func: t.Callable) -> t.Callable: @wraps(func) - async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None: - target = _get_arg_value(target_arg, args, kwargs) + async def wrapper(*args, **kwargs) -> None: + bound_args = function.get_bound_args(func, args, kwargs) + target = function.get_arg_value(name_or_pos, bound_args) if not isinstance(target, Member): log.trace("The target is not a discord.Member; skipping role hierarchy check.") - await func(self, ctx, *args, **kwargs) + await func(*args, **kwargs) return + ctx = function.get_arg_value(1, bound_args) cmd = ctx.command.name actor = ctx.author + if target.top_role >= actor.top_role: log.info( f"{actor} ({actor.id}) attempted to {cmd} " @@ -234,26 +237,6 @@ def respect_role_hierarchy(target_arg: Argument = 0) -> t.Callable: "someone with an equal or higher top role." ) else: - await func(self, ctx, *args, **kwargs) - return inner - return wrap - - -def _get_arg_value(target_arg: Argument, args: t.Tuple, kwargs: t.Dict[str, t.Any]) -> t.Any: - """ - Return the value of the arg at the given position or name `target_arg`. - - Use an integer as a position if the target argument is positional. - Use a string as a parameter name if the target argument is a keyword argument. - - Raise ValueError if `target_arg` cannot be found. - """ - try: - return kwargs[target_arg] - except KeyError: - try: - return args[target_arg] - except IndexError: - raise ValueError(f"Could not find target argument at position {target_arg}") - except TypeError: - raise ValueError(f"Could not find target kwarg with key {target_arg!r}") + await func(*args, **kwargs) + return wrapper + return decorator -- cgit v1.2.3 From 578477da164ef8c3ff77400b22e608a7d4c6d5f2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jul 2020 13:29:51 -0700 Subject: Decorators: remove default value for respect_role_hierarchy Explicit is better than implicit, and this default value wasn't much of a convenience. --- bot/cogs/moderation/infractions.py | 4 ++-- bot/decorators.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 8df642428..d720c2911 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -230,7 +230,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_infraction(ctx, infraction, user, action()) - @respect_role_hierarchy() + @respect_role_hierarchy(2) async def apply_kick(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None: """Apply a kick infraction with kwargs passed to `post_infraction`.""" infraction = await utils.post_infraction(ctx, user, "kick", reason, active=False, **kwargs) @@ -245,7 +245,7 @@ class Infractions(InfractionScheduler, commands.Cog): action = user.kick(reason=reason) await self.apply_infraction(ctx, infraction, user, action) - @respect_role_hierarchy() + @respect_role_hierarchy(2) async def apply_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None: """ Apply a ban infraction with kwargs passed to `post_infraction`. diff --git a/bot/decorators.py b/bot/decorators.py index b4bf9ba05..6b2214f53 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -200,7 +200,7 @@ def redirect_output(destination_channel: int, bypass_roles: t.Container[int] = N return wrap -def respect_role_hierarchy(name_or_pos: Argument = 2) -> t.Callable: +def respect_role_hierarchy(name_or_pos: Argument) -> t.Callable: """ Ensure the highest role of the invoking member is greater than that of the target member. -- cgit v1.2.3 From bb0cb84546820b9399c2b08edda641b079898538 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jul 2020 13:36:45 -0700 Subject: Decorators: use type aliases from function module --- bot/decorators.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index 6b2214f53..eefe2f9ba 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -18,10 +18,8 @@ from bot.utils.checks import in_whitelist_check, with_role_check, without_role_c log = logging.getLogger(__name__) __lock_dicts = defaultdict(WeakValueDictionary) -Argument = t.Union[int, str] -BoundArgs = t.OrderedDict[str, t.Any] _IdCallableReturn = t.Union[t.Hashable, t.Awaitable[t.Hashable]] -_IdCallable = t.Callable[[BoundArgs], _IdCallableReturn] +_IdCallable = t.Callable[[function.BoundArgs], _IdCallableReturn] ResourceId = t.Union[t.Hashable, _IdCallable] @@ -200,7 +198,7 @@ def redirect_output(destination_channel: int, bypass_roles: t.Container[int] = N return wrap -def respect_role_hierarchy(name_or_pos: Argument) -> t.Callable: +def respect_role_hierarchy(name_or_pos: function.Argument) -> t.Callable: """ Ensure the highest role of the invoking member is greater than that of the target member. -- cgit v1.2.3 From c9a3a73c93d553b65df282dd5a42fa694dd3dfe2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jul 2020 13:37:38 -0700 Subject: Decorators: remove redundant word in docstring --- bot/decorators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/decorators.py b/bot/decorators.py index eefe2f9ba..cffd97440 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -38,7 +38,7 @@ def in_whitelist( - `channels`: a container with channel ids for whitelisted channels - `categories`: a container with category ids for whitelisted categories - - `roles`: a container with with role ids for whitelisted roles + - `roles`: a container with role ids for whitelisted roles If the command was invoked in a context that was not whitelisted, the member is either redirected to the `redirect` channel that was passed (default: #bot-commands) or simply -- cgit v1.2.3 From 0010d2d332f64bebd0ca4eadeac682c7f83014f9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jul 2020 13:48:56 -0700 Subject: Decorators: wrap mutually_exclusive to use get_arg_value Instead of taking a callable, this wrapper just takes a name or position to get the resource ID. --- bot/cogs/reminders.py | 10 ++++------ bot/decorators.py | 17 ++++++++++++++++- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 1a0a9d303..30f7c8876 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -4,7 +4,6 @@ import random import textwrap import typing as t from datetime import datetime, timedelta -from functools import partial from operator import itemgetter import discord @@ -15,10 +14,9 @@ from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, STAFF_ROLES from bot.converters import Duration -from bot.decorators import mutually_exclusive +from bot.decorators import mutually_exclusive_arg from bot.pagination import LinePaginator from bot.utils.checks import without_role_check -from bot.utils.function import get_arg_value from bot.utils.messages import send_denial from bot.utils.scheduling import Scheduler from bot.utils.time import humanize_delta @@ -168,7 +166,7 @@ class Reminders(Cog): log.trace(f"Scheduling new task #{reminder['id']}") self.schedule_reminder(reminder) - @mutually_exclusive(NAMESPACE, lambda args: get_arg_value("reminder", args)["id"]) + @mutually_exclusive_arg(NAMESPACE, "reminder", itemgetter("id")) async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: """Send the reminder.""" is_valid, user, channel = self.ensure_valid_reminder(reminder) @@ -375,7 +373,7 @@ class Reminders(Cog): mention_ids = [mention.id for mention in mentions] await self.edit_reminder(ctx, id_, {"mentions": mention_ids}) - @mutually_exclusive(NAMESPACE, partial(get_arg_value, "id_")) + @mutually_exclusive_arg(NAMESPACE, "id_") async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: """Edits a reminder with the given payload, then sends a confirmation message.""" reminder = await self._edit_reminder(id_, payload) @@ -393,7 +391,7 @@ class Reminders(Cog): await self._reschedule_reminder(reminder) @remind_group.command("delete", aliases=("remove", "cancel")) - @mutually_exclusive(NAMESPACE, partial(get_arg_value, "id_")) + @mutually_exclusive_arg(NAMESPACE, "id_") async def delete_reminder(self, ctx: Context, id_: int) -> None: """Delete one of your active reminders.""" await self.bot.api_client.delete(f"bot/reminders/{id_}") diff --git a/bot/decorators.py b/bot/decorators.py index cffd97440..c9e4a0560 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -5,7 +5,7 @@ import random import typing as t from collections import defaultdict from contextlib import suppress -from functools import wraps +from functools import partial, wraps from weakref import WeakValueDictionary from discord import Colour, Embed, Member, NotFound @@ -150,6 +150,21 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call return decorator +def mutually_exclusive_arg( + namespace: t.Hashable, + name_or_pos: function.Argument, + func: t.Callable[[t.Any], _IdCallableReturn] = None +) -> t.Callable: + """ + Apply `mutually_exclusive` using the value of the arg at the given name/position as the ID. + + `func` is an optional callable or awaitable which will return the ID given the argument value. + See `mutually_exclusive` docs for more information. + """ + decorator_func = partial(mutually_exclusive, namespace) + return function.get_arg_value_wrapper(decorator_func, name_or_pos, func) + + def redirect_output(destination_channel: int, bypass_roles: t.Container[int] = None) -> t.Callable: """ Changes the channel in the context of the command to redirect the output to a certain channel. -- cgit v1.2.3 From 90e0a3707c77b41144964dcfd3eb82714ac26b25 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jul 2020 13:59:38 -0700 Subject: Decorators: add some trace logging --- bot/decorators.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/decorators.py b/bot/decorators.py index c9e4a0560..e370bf834 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -228,6 +228,8 @@ def respect_role_hierarchy(name_or_pos: function.Argument) -> t.Callable: def decorator(func: t.Callable) -> t.Callable: @wraps(func) async def wrapper(*args, **kwargs) -> None: + log.trace(f"{func.__name__}: respect role hierarchy decorator called") + bound_args = function.get_bound_args(func, args, kwargs) target = function.get_arg_value(name_or_pos, bound_args) @@ -250,6 +252,7 @@ def respect_role_hierarchy(name_or_pos: function.Argument) -> t.Callable: "someone with an equal or higher top role." ) else: + log.trace(f"{func.__name__}: {target.top_role=} < {actor.top_role=}; calling func") await func(*args, **kwargs) return wrapper return decorator -- cgit v1.2.3 From 44fa8d7aef62b0cac5edede8002ae8cf8ac8c74b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 31 Jul 2020 22:22:13 -0700 Subject: Decorators: optionally raise an exception if resource is locked The exception will facilitate user feedback for commands which use the decorator. --- bot/decorators.py | 19 +++++++++++++++---- bot/errors.py | 20 ++++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 bot/errors.py diff --git a/bot/decorators.py b/bot/decorators.py index e370bf834..15386e506 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -12,6 +12,7 @@ from discord import Colour, Embed, Member, NotFound from discord.ext.commands import Cog, Command, Context, check from bot.constants import Channels, ERROR_REPLIES, RedirectOutput +from bot.errors import LockedResourceError from bot.utils import function from bot.utils.checks import in_whitelist_check, with_role_check, without_role_check @@ -98,12 +99,18 @@ def locked() -> t.Callable: return wrap -def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Callable: +def mutually_exclusive( + namespace: t.Hashable, + resource_id: ResourceId, + *, + raise_error: bool = False, +) -> t.Callable: """ Turn the decorated coroutine function into a mutually exclusive operation on a `resource_id`. If any other mutually exclusive function currently holds the lock for a resource, do not run the - decorated function and return None. + decorated function and return None. If `raise_error` is True, raise `LockedResourceError` if + the lock cannot be acquired. `namespace` is an identifier used to prevent collisions among resource IDs. @@ -145,6 +152,8 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call return await func(*args, **kwargs) else: log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked") + if raise_error: + raise LockedResourceError(str(namespace), id_) return wrapper return decorator @@ -153,7 +162,9 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call def mutually_exclusive_arg( namespace: t.Hashable, name_or_pos: function.Argument, - func: t.Callable[[t.Any], _IdCallableReturn] = None + func: t.Callable[[t.Any], _IdCallableReturn] = None, + *, + raise_error: bool = False, ) -> t.Callable: """ Apply `mutually_exclusive` using the value of the arg at the given name/position as the ID. @@ -161,7 +172,7 @@ def mutually_exclusive_arg( `func` is an optional callable or awaitable which will return the ID given the argument value. See `mutually_exclusive` docs for more information. """ - decorator_func = partial(mutually_exclusive, namespace) + decorator_func = partial(mutually_exclusive, namespace, raise_error=raise_error) return function.get_arg_value_wrapper(decorator_func, name_or_pos, func) diff --git a/bot/errors.py b/bot/errors.py new file mode 100644 index 000000000..34de3c2b1 --- /dev/null +++ b/bot/errors.py @@ -0,0 +1,20 @@ +from typing import Hashable + + +class LockedResourceError(RuntimeError): + """ + Exception raised when an operation is attempted on a locked resource. + + Attributes: + `type` -- name of the locked resource's type + `resource_id` -- ID of the locked resource + """ + + def __init__(self, resource_type: str, resource_id: Hashable): + self.type = resource_type + self.id = resource_id + + super().__init__( + f"Cannot operate on {self.type.lower()} `{self.id}`; " + "it is currently locked and in use by another operation." + ) -- cgit v1.2.3 From b1a677cd0a64b2ad4da400a492d9d5157d558546 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 31 Jul 2020 22:26:59 -0700 Subject: Send users an error message if command raises LockedResourceError --- bot/cogs/error_handler.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 233851e41..a9c6d50b7 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -9,6 +9,7 @@ from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Channels from bot.converters import TagNameConverter +from bot.errors import LockedResourceError from bot.utils.checks import InWhitelistCheckFailure log = logging.getLogger(__name__) @@ -66,6 +67,8 @@ class ErrorHandler(Cog): elif isinstance(e, errors.CommandInvokeError): if isinstance(e.original, ResponseCodeError): await self.handle_api_error(ctx, e.original) + elif isinstance(e.original, LockedResourceError): + await ctx.send(f"{e.original} Please wait for it to finish and try again later.") else: await self.handle_unexpected_error(ctx, e.original) return # Exit early to avoid logging. -- cgit v1.2.3 From ea74cd51b3821caf0298eacd451d0519cb4d1b9a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 31 Jul 2020 22:29:42 -0700 Subject: Reminders: show error to users if reminder is in use Silent failure is confusing to users. Showing an error message clears up why nothing happened with their command. --- bot/cogs/reminders.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 30f7c8876..292435f24 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -166,7 +166,7 @@ class Reminders(Cog): log.trace(f"Scheduling new task #{reminder['id']}") self.schedule_reminder(reminder) - @mutually_exclusive_arg(NAMESPACE, "reminder", itemgetter("id")) + @mutually_exclusive_arg(NAMESPACE, "reminder", itemgetter("id"), raise_error=True) async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: """Send the reminder.""" is_valid, user, channel = self.ensure_valid_reminder(reminder) @@ -373,7 +373,7 @@ class Reminders(Cog): mention_ids = [mention.id for mention in mentions] await self.edit_reminder(ctx, id_, {"mentions": mention_ids}) - @mutually_exclusive_arg(NAMESPACE, "id_") + @mutually_exclusive_arg(NAMESPACE, "id_", raise_error=True) async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: """Edits a reminder with the given payload, then sends a confirmation message.""" reminder = await self._edit_reminder(id_, payload) @@ -391,7 +391,7 @@ class Reminders(Cog): await self._reschedule_reminder(reminder) @remind_group.command("delete", aliases=("remove", "cancel")) - @mutually_exclusive_arg(NAMESPACE, "id_") + @mutually_exclusive_arg(NAMESPACE, "id_", raise_error=True) async def delete_reminder(self, ctx: Context, id_: int) -> None: """Delete one of your active reminders.""" await self.bot.api_client.delete(f"bot/reminders/{id_}") -- cgit v1.2.3 From 6618359ac3484544fe4e88d66dd8b5669254c154 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 31 Jul 2020 22:40:18 -0700 Subject: Reminders: use singular form for mutually exclusive namespace The exception it raises reads better if the singular form of the word is used. --- bot/cogs/reminders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 292435f24..be97d34b6 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -23,7 +23,7 @@ from bot.utils.time import humanize_delta log = logging.getLogger(__name__) -NAMESPACE = "reminders" # Used for the mutually_exclusive decorator; constant to prevent typos +NAMESPACE = "reminder" # Used for the mutually_exclusive decorator; constant to prevent typos WHITELISTED_CHANNELS = Guild.reminder_whitelist MAXIMUM_REMINDERS = 5 -- cgit v1.2.3 From 675d9a2abf7212f4680d124c72da1a914c87756c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 1 Aug 2020 08:54:42 -0700 Subject: Decorators: fix type annotations for checks The annotation was previously changed on the basis of an incorrect return annotation PyCharm inferred for `check()`. --- bot/decorators.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index 15386e506..96f0d1408 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -9,7 +9,7 @@ from functools import partial, wraps from weakref import WeakValueDictionary from discord import Colour, Embed, Member, NotFound -from discord.ext.commands import Cog, Command, Context, check +from discord.ext.commands import Cog, Context, check from bot.constants import Channels, ERROR_REPLIES, RedirectOutput from bot.errors import LockedResourceError @@ -31,7 +31,7 @@ def in_whitelist( roles: t.Container[int] = (), redirect: t.Optional[int] = Channels.bot_commands, fail_silently: bool = False, -) -> Command: +) -> t.Callable: """ Check if a command was issued in a whitelisted context. @@ -52,7 +52,7 @@ def in_whitelist( return check(predicate) -def with_role(*role_ids: int) -> Command: +def with_role(*role_ids: int) -> t.Callable: """Returns True if the user has any one of the roles in role_ids.""" async def predicate(ctx: Context) -> bool: """With role checker predicate.""" @@ -60,7 +60,7 @@ def with_role(*role_ids: int) -> Command: return check(predicate) -def without_role(*role_ids: int) -> Command: +def without_role(*role_ids: int) -> t.Callable: """Returns True if the user does not have any of the roles in role_ids.""" async def predicate(ctx: Context) -> bool: return without_role_check(ctx, *role_ids) -- cgit v1.2.3 From d96e605162d40f8890bdc57734631a0cc47b8e13 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 1 Aug 2020 09:00:00 -0700 Subject: Explicitly use kwarg with respect_role_hierarchy Clarify the significance of the argument being passed. --- bot/cogs/moderation/infractions.py | 4 ++-- bot/decorators.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index d720c2911..b68b5f117 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -230,7 +230,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_infraction(ctx, infraction, user, action()) - @respect_role_hierarchy(2) + @respect_role_hierarchy(member_arg=2) async def apply_kick(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None: """Apply a kick infraction with kwargs passed to `post_infraction`.""" infraction = await utils.post_infraction(ctx, user, "kick", reason, active=False, **kwargs) @@ -245,7 +245,7 @@ class Infractions(InfractionScheduler, commands.Cog): action = user.kick(reason=reason) await self.apply_infraction(ctx, infraction, user, action) - @respect_role_hierarchy(2) + @respect_role_hierarchy(member_arg=2) async def apply_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None: """ Apply a ban infraction with kwargs passed to `post_infraction`. diff --git a/bot/decorators.py b/bot/decorators.py index 96f0d1408..0e84cf37e 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -224,14 +224,14 @@ def redirect_output(destination_channel: int, bypass_roles: t.Container[int] = N return wrap -def respect_role_hierarchy(name_or_pos: function.Argument) -> t.Callable: +def respect_role_hierarchy(member_arg: function.Argument) -> t.Callable: """ Ensure the highest role of the invoking member is greater than that of the target member. If the condition fails, a warning is sent to the invoking context. A target which is not an instance of discord.Member will always pass. - `name_or_pos` is the keyword name or position index of the parameter of the decorated command + `member_arg` is the keyword name or position index of the parameter of the decorated command whose value is the target member. This decorator must go before (below) the `command` decorator. @@ -242,7 +242,7 @@ def respect_role_hierarchy(name_or_pos: function.Argument) -> t.Callable: log.trace(f"{func.__name__}: respect role hierarchy decorator called") bound_args = function.get_bound_args(func, args, kwargs) - target = function.get_arg_value(name_or_pos, bound_args) + target = function.get_arg_value(member_arg, bound_args) if not isinstance(target, Member): log.trace("The target is not a discord.Member; skipping role hierarchy check.") -- cgit v1.2.3 From a2218956c881de81b41129e707091a43f6477d24 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sun, 2 Aug 2020 22:19:22 +0200 Subject: Verification: add initial on join message This message will be sent via direct message to each user who joins the guild. --- bot/cogs/verification.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index ae156cf70..2293cad28 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -12,6 +12,13 @@ from bot.utils.checks import InWhitelistCheckFailure, without_role_check log = logging.getLogger(__name__) +ON_JOIN_MESSAGE = f""" +Hello! Welcome to Python Discord! + +In order to send messages, you first have to accept our rules. To do so, please visit \ +<#{constants.Channels.verification}>. Thank you! +""" + WELCOME_MESSAGE = f""" Hello! Welcome to the server, and thanks for verifying yourself! -- cgit v1.2.3 From fad796101d5f641c0c4315244303c8727df0462f Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sun, 2 Aug 2020 22:25:18 +0200 Subject: Verification: adjust & rename welcome message Let's give it a better name so that it's clear when this message is sent. The initial words are adjusted to avoid repetition after the on join message. --- bot/cogs/verification.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 2293cad28..c10940817 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -19,8 +19,8 @@ In order to send messages, you first have to accept our rules. To do so, please <#{constants.Channels.verification}>. Thank you! """ -WELCOME_MESSAGE = f""" -Hello! Welcome to the server, and thanks for verifying yourself! +VERIFIED_MESSAGE = f""" +Thanks for verifying yourself! For your records, these are the documents you accepted: @@ -121,7 +121,7 @@ class Verification(Cog): log.debug(f"{ctx.author} called !accept. Assigning the 'Developer' role.") await ctx.author.add_roles(Object(constants.Roles.verified), reason="Accepted the rules") try: - await ctx.author.send(WELCOME_MESSAGE) + await ctx.author.send(VERIFIED_MESSAGE) except Forbidden: log.info(f"Sending welcome message failed for {ctx.author}.") finally: -- cgit v1.2.3 From 0521af684f82fec50b46f744aebf76ccee88f318 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sun, 2 Aug 2020 23:06:21 +0200 Subject: Verification: send initial message on member join --- bot/cogs/verification.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index c10940817..1c1919bdf 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -1,7 +1,7 @@ import logging from contextlib import suppress -from discord import Colour, Forbidden, Message, NotFound, Object +from discord import Colour, Forbidden, Member, Message, NotFound, Object from discord.ext.commands import Cog, Context, command from bot import constants @@ -53,6 +53,16 @@ class Verification(Cog): """Get currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") + @Cog.listener() + async def on_member_join(self, member: Member) -> None: + """Attempt to send initial direct message to each new member.""" + if member.guild.id != constants.Guild.id: + return # Only listen for PyDis events + + log.trace(f"Sending on join message to new member: {member.id}") + with suppress(Forbidden): + await member.send(ON_JOIN_MESSAGE) + @Cog.listener() async def on_message(self, message: Message) -> None: """Check new message event for messages to the checkpoint channel & process.""" -- cgit v1.2.3 From 6c934ebdf5dfa3025347a1b345b41f0f62ec76cb Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 3 Aug 2020 10:04:58 +0200 Subject: Sort all load_extension groups alphabetically. --- bot/__main__.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/bot/__main__.py b/bot/__main__.py index c2271cd16..fcef2239e 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -34,35 +34,34 @@ bot = Bot( ) # Internal/debug +bot.load_extension("bot.cogs.config_verifier") bot.load_extension("bot.cogs.error_handler") bot.load_extension("bot.cogs.filtering") bot.load_extension("bot.cogs.logging") bot.load_extension("bot.cogs.security") -bot.load_extension("bot.cogs.config_verifier") # Commands, etc bot.load_extension("bot.cogs.antimalware") bot.load_extension("bot.cogs.antispam") bot.load_extension("bot.cogs.bot") bot.load_extension("bot.cogs.clean") +bot.load_extension("bot.cogs.doc") bot.load_extension("bot.cogs.extensions") bot.load_extension("bot.cogs.help") - -bot.load_extension("bot.cogs.doc") bot.load_extension("bot.cogs.verification") # Feature cogs bot.load_extension("bot.cogs.alias") -bot.load_extension("bot.cogs.filter_lists") bot.load_extension("bot.cogs.defcon") bot.load_extension("bot.cogs.dm_relay") bot.load_extension("bot.cogs.duck_pond") bot.load_extension("bot.cogs.eval") +bot.load_extension("bot.cogs.filter_lists") bot.load_extension("bot.cogs.information") bot.load_extension("bot.cogs.jams") bot.load_extension("bot.cogs.moderation") -bot.load_extension("bot.cogs.python_news") bot.load_extension("bot.cogs.off_topic_names") +bot.load_extension("bot.cogs.python_news") bot.load_extension("bot.cogs.reddit") bot.load_extension("bot.cogs.reminders") bot.load_extension("bot.cogs.site") -- cgit v1.2.3 From fff5493b9cec4ed920acee82698c34eef76206a4 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 3 Aug 2020 10:07:06 +0200 Subject: Adding a beautiful walrus to filter_lists.py. Thanks @Den4200! --- bot/cogs/filter_lists.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py index 52db1fcb5..496d45322 100644 --- a/bot/cogs/filter_lists.py +++ b/bot/cogs/filter_lists.py @@ -147,8 +147,8 @@ class FilterLists(Cog): for content, metadata in result.items(): line = f"• `{content}`" - if metadata.get("comment"): - line += f" - {metadata.get('comment')}" + if comment := metadata.get("comment"): + line += f" - {comment}" lines.append(line) lines = sorted(lines) -- cgit v1.2.3 From 5a339639e598f2e84ec9367dd2ea519befd4f011 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 3 Aug 2020 10:10:17 +0200 Subject: Change some errant single quotes to doubles. --- bot/cogs/filter_lists.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py index 496d45322..e50411d51 100644 --- a/bot/cogs/filter_lists.py +++ b/bot/cogs/filter_lists.py @@ -74,10 +74,10 @@ class FilterLists(Cog): # Try to add the item to the database log.trace(f"Trying to add the {content} item to the {list_type} {allow_type}") payload = { - 'allowed': allowed, - 'type': list_type, - 'content': content, - 'comment': comment, + "allowed": allowed, + "type": list_type, + "content": content, + "comment": comment, } try: -- cgit v1.2.3 From 7a84ed8dbec5c1497a08865fa3144eb867dc1636 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 3 Aug 2020 10:15:09 +0200 Subject: Move function params to 4-space indentation. --- bot/cogs/filter_lists.py | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py index e50411d51..8aa5a0a08 100644 --- a/bot/cogs/filter_lists.py +++ b/bot/cogs/filter_lists.py @@ -45,12 +45,12 @@ class FilterLists(Cog): ) async def _add_data( - self, - ctx: Context, - allowed: bool, - list_type: ValidFilterListType, - content: str, - comment: Optional[str] = None, + self, + ctx: Context, + allowed: bool, + list_type: ValidFilterListType, + content: str, + comment: Optional[str] = None, ) -> None: """Add an item to a filterlist.""" allow_type = "whitelist" if allowed else "blacklist" @@ -198,24 +198,24 @@ class FilterLists(Cog): @whitelist.command(name="add", aliases=("a", "set")) async def allow_add( - self, - ctx: Context, - list_type: ValidFilterListType, - content: str, - *, - comment: Optional[str] = None, + self, + ctx: Context, + list_type: ValidFilterListType, + content: str, + *, + comment: Optional[str] = None, ) -> None: """Add an item to the specified allowlist.""" await self._add_data(ctx, True, list_type, content, comment) @blacklist.command(name="add", aliases=("a", "set")) async def deny_add( - self, - ctx: Context, - list_type: ValidFilterListType, - content: str, - *, - comment: Optional[str] = None, + self, + ctx: Context, + list_type: ValidFilterListType, + content: str, + *, + comment: Optional[str] = None, ) -> None: """Add an item to the specified denylist.""" await self._add_data(ctx, False, list_type, content, comment) -- cgit v1.2.3 From 134ea0e449005a771c8184189a8d319e5d4b26a0 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 3 Aug 2020 13:33:17 +0200 Subject: Move function params to 4-space indentation. --- bot/bot.py | 4 ++-- bot/cogs/filter_lists.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 4492feaa9..756449293 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -51,7 +51,7 @@ class Bot(commands.Bot): self.stats = AsyncStatsClient(self.loop, statsd_url, 8125, prefix="bot") - async def _cache_filter_list_data(self) -> None: + async def cache_filter_list_data(self) -> None: """Cache all the data in the FilterList on the site.""" full_cache = await self.api_client.get('bot/filter-lists') @@ -123,7 +123,7 @@ class Bot(commands.Bot): self.api_client.recreate(force=True, connector=self._connector) # Build the FilterList cache - self.loop.create_task(self._cache_filter_list_data()) + self.loop.create_task(self.cache_filter_list_data()) def add_cog(self, cog: commands.Cog) -> None: """Adds a "cog" to the bot and logs the operation.""" diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py index 8aa5a0a08..6249774bb 100644 --- a/bot/cogs/filter_lists.py +++ b/bot/cogs/filter_lists.py @@ -168,6 +168,11 @@ class FilterLists(Cog): await ctx.send(embed=embed) await ctx.message.add_reaction("❌") + async def _sync_data(self) -> None: + """Syncs the filterlists with the API.""" + log.trace("Synchronizing FilterList cache with data from the API.") + await self.bot.cache_filter_list_data() + @staticmethod async def _validate_guild_invite(ctx: Context, invite: str) -> dict: """ @@ -240,6 +245,16 @@ class FilterLists(Cog): """Get the contents of a specified denylist.""" await self._list_all_data(ctx, False, list_type) + @whitelist.command(name="sync", aliases=("s",)) + async def allow_sync(self, _: Context) -> None: + """Syncs both allowlists and denylists with the API.""" + await self._sync_data() + + @blacklist.command(name="sync", aliases=("s",)) + async def deny_sync(self, _: Context) -> None: + """Syncs both allowlists and denylists with the API.""" + await self._sync_data() + def cog_check(self, ctx: Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" return with_role_check(ctx, *constants.MODERATION_ROLES) -- cgit v1.2.3 From 312d31d408e2580a08b5b36a6f885f6d1a5955b9 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 3 Aug 2020 14:08:08 +0200 Subject: Add some feedback to the _sync_data helper. Previously, this would not provide any feedback at all, which is really terrible UX. Sorry about that. This also adds error handling in case the API call fails. --- bot/cogs/filter_lists.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py index 6249774bb..c15adc461 100644 --- a/bot/cogs/filter_lists.py +++ b/bot/cogs/filter_lists.py @@ -168,10 +168,18 @@ class FilterLists(Cog): await ctx.send(embed=embed) await ctx.message.add_reaction("❌") - async def _sync_data(self) -> None: + async def _sync_data(self, ctx: Context) -> None: """Syncs the filterlists with the API.""" - log.trace("Synchronizing FilterList cache with data from the API.") - await self.bot.cache_filter_list_data() + try: + log.trace("Attempting to sync FilterList cache with data from the API.") + await self.bot.cache_filter_list_data() + await ctx.message.add_reaction("✅") + except ResponseCodeError as e: + log.debug( + f"{ctx.author} tried to sync FilterList cache data but " + f"the API raised an unexpected error: {e}" + ) + await ctx.message.add_reaction("❌") @staticmethod async def _validate_guild_invite(ctx: Context, invite: str) -> dict: @@ -246,14 +254,14 @@ class FilterLists(Cog): await self._list_all_data(ctx, False, list_type) @whitelist.command(name="sync", aliases=("s",)) - async def allow_sync(self, _: Context) -> None: + async def allow_sync(self, ctx: Context) -> None: """Syncs both allowlists and denylists with the API.""" - await self._sync_data() + await self._sync_data(ctx) @blacklist.command(name="sync", aliases=("s",)) - async def deny_sync(self, _: Context) -> None: + async def deny_sync(self, ctx: Context) -> None: """Syncs both allowlists and denylists with the API.""" - await self._sync_data() + await self._sync_data(ctx) def cog_check(self, ctx: Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" -- cgit v1.2.3 From e73f77a34c3b2f0ec226acbdfc490f93896784d0 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 3 Aug 2020 17:40:09 +0200 Subject: Add support for plural FilterList types. This will allow mods to use '!whitelist get guild_invites' in addition to '!whitelist get guild_invite' This is just a naive implementation which works if the plural form is a simple s at the end of the word. It's implemented into the converter. --- bot/converters.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index c9f525dd1..1358cbf1e 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -101,11 +101,23 @@ class ValidFilterListType(Converter): list_type = list_type.upper() if list_type not in valid_types: - valid_types_list = '\n'.join([f"• {type_.lower()}" for type_ in valid_types]) - raise BadArgument( - f"You have provided an invalid list type!\n\n" - f"Please provide one of the following: \n{valid_types_list}" - ) + + # Maybe the user is using the plural form of this type, + # e.g. "guild_invites" instead of "guild_invite". + # + # This code will support the simple plural form (a single 's' at the end), + # which works for all current list types, but if a list type is added in the future + # which has an irregular plural form (like 'ies'), this code will need to be + # refactored to support this. + if list_type.endswith("S") and list_type[:-1] in valid_types: + list_type = list_type[:-1] + + else: + valid_types_list = '\n'.join([f"• {type_.lower()}" for type_ in valid_types]) + raise BadArgument( + f"You have provided an invalid list type!\n\n" + f"Please provide one of the following: \n{valid_types_list}" + ) return list_type -- cgit v1.2.3 From 239fa5f43ab79435151657671ebcf21eac706fc6 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Tue, 4 Aug 2020 15:30:34 +0100 Subject: Revert "Disabled burst_shared filter temporarily" This reverts commit be14db91b1c70993773e67cfa663fef0cfa85666. --- config-default.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config-default.yml b/config-default.yml index 14a073611..aacbe170f 100644 --- a/config-default.yml +++ b/config-default.yml @@ -358,6 +358,10 @@ anti_spam: interval: 10 max: 7 + burst_shared: + interval: 10 + max: 20 + chars: interval: 5 max: 3_000 -- cgit v1.2.3 From 226af0e676e072696dba503e3873deeaf73202ac Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 18:01:47 +0200 Subject: Verification: add @Unverified role to config --- bot/constants.py | 1 + config-default.yml | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index cf4f3f666..cce64a7c4 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -454,6 +454,7 @@ class Roles(metaclass=YAMLGetter): partners: int python_community: int team_leaders: int + unverified: int verified: int # This is the Developers role on PyDis, here named verified for readability reasons. diff --git a/config-default.yml b/config-default.yml index fc093cc32..21a3eca87 100644 --- a/config-default.yml +++ b/config-default.yml @@ -225,8 +225,8 @@ guild: partners: 323426753857191936 python_community: &PY_COMMUNITY_ROLE 458226413825294336 - # This is the Developers role on PyDis, here named verified for readability reasons - verified: 352427296948486144 + unverified: 739794855945044069 + verified: 352427296948486144 # @Developers on PyDis # Staff admins: &ADMINS_ROLE 267628507062992896 -- cgit v1.2.3 From 5006105f14e575366575c1091af7cfd2b2da7abd Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 18:08:32 +0200 Subject: Verification: refactor `discord` imports Let's access these via the qualified name. The amount of imported names was starting to get unwieldy. --- bot/cogs/verification.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 1c1919bdf..f86356f33 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -1,7 +1,7 @@ import logging from contextlib import suppress -from discord import Colour, Forbidden, Member, Message, NotFound, Object +import discord from discord.ext.commands import Cog, Context, command from bot import constants @@ -54,17 +54,17 @@ class Verification(Cog): return self.bot.get_cog("ModLog") @Cog.listener() - async def on_member_join(self, member: Member) -> None: + async def on_member_join(self, member: discord.Member) -> None: """Attempt to send initial direct message to each new member.""" if member.guild.id != constants.Guild.id: return # Only listen for PyDis events log.trace(f"Sending on join message to new member: {member.id}") - with suppress(Forbidden): + with suppress(discord.Forbidden): await member.send(ON_JOIN_MESSAGE) @Cog.listener() - async def on_message(self, message: Message) -> None: + async def on_message(self, message: discord.Message) -> None: """Check new message event for messages to the checkpoint channel & process.""" if message.channel.id != constants.Channels.verification: return # Only listen for #checkpoint messages @@ -91,7 +91,7 @@ class Verification(Cog): # Send pretty mod log embed to mod-alerts await self.mod_log.send_log_message( icon_url=constants.Icons.filtering, - colour=Colour(constants.Colours.soft_red), + colour=discord.Colour(constants.Colours.soft_red), title=f"User/Role mentioned in {message.channel.name}", text=embed_text, thumbnail=message.author.avatar_url_as(static_format="png"), @@ -120,7 +120,7 @@ class Verification(Cog): ) log.trace(f"Deleting the message posted by {ctx.author}") - with suppress(NotFound): + with suppress(discord.NotFound): await ctx.message.delete() @command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True) @@ -129,14 +129,14 @@ class Verification(Cog): async def accept_command(self, ctx: Context, *_) -> None: # We don't actually care about the args """Accept our rules and gain access to the rest of the server.""" log.debug(f"{ctx.author} called !accept. Assigning the 'Developer' role.") - await ctx.author.add_roles(Object(constants.Roles.verified), reason="Accepted the rules") + await ctx.author.add_roles(discord.Object(constants.Roles.verified), reason="Accepted the rules") try: await ctx.author.send(VERIFIED_MESSAGE) - except Forbidden: + except discord.Forbidden: log.info(f"Sending welcome message failed for {ctx.author}.") finally: log.trace(f"Deleting accept message by {ctx.author}.") - with suppress(NotFound): + with suppress(discord.NotFound): self.mod_log.ignore(constants.Event.message_delete, ctx.message.id) await ctx.message.delete() @@ -156,7 +156,7 @@ class Verification(Cog): return log.debug(f"{ctx.author} called !subscribe. Assigning the 'Announcements' role.") - await ctx.author.add_roles(Object(constants.Roles.announcements), reason="Subscribed to announcements") + await ctx.author.add_roles(discord.Object(constants.Roles.announcements), reason="Subscribed to announcements") log.trace(f"Deleting the message posted by {ctx.author}.") @@ -180,7 +180,9 @@ class Verification(Cog): return log.debug(f"{ctx.author} called !unsubscribe. Removing the 'Announcements' role.") - await ctx.author.remove_roles(Object(constants.Roles.announcements), reason="Unsubscribed from announcements") + await ctx.author.remove_roles( + discord.Object(constants.Roles.announcements), reason="Unsubscribed from announcements" + ) log.trace(f"Deleting the message posted by {ctx.author}.") -- cgit v1.2.3 From 8415ac8da18054be3258ee7816a70c58a3a9322a Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 18:09:06 +0200 Subject: Verification: define time constants --- bot/cogs/verification.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index f86356f33..95d92899b 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -39,6 +39,9 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! <#{constants.Channels.bot_commands}>. """ +UNVERIFIED_AFTER = 3 # Amount of days after which non-Developers receive the @Unverified role +KICKED_AFTER = 30 # Amount of days after which non-Developers get kicked from the guild + BOT_MESSAGE_DELETE_DELAY = 10 -- cgit v1.2.3 From f0e0dcc599f974bb563e298199fcf5a76b1cfbe7 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 18:13:37 +0200 Subject: Verification: implement `check_users` coroutine See docstring for details. The coroutine will be registered as a task at a later point. --- bot/cogs/verification.py | 60 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 95d92899b..ea4874450 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -1,5 +1,8 @@ +import asyncio import logging +import typing as t from contextlib import suppress +from datetime import datetime, timedelta import discord from discord.ext.commands import Cog, Context, command @@ -51,6 +54,61 @@ class Verification(Cog): def __init__(self, bot: Bot): self.bot = bot + async def _kick_members(self, members: t.Set[discord.Member]) -> int: + """Kick `members` from the PyDis guild.""" + ... + + async def _give_role(self, members: t.Set[discord.Member], role: discord.Role) -> int: + """Give `role` to all `members`.""" + ... + + async def check_users(self) -> None: + """ + Periodically check in on the verification status of PyDis members. + + This coroutine performs two actions: + * Find members who have not verified for `UNVERIFIED_AFTER` and give them the @Unverified role + * Find members who have not verified for `KICKED_AFTER` and kick them from the guild + + Within the body of this coroutine, we only select the members for each action. The work is then + delegated to `_kick_members` and `_give_role`. After each run, a report is sent via modlog. + """ + await self.bot.wait_until_guild_available() # Ensure cache is ready + pydis = self.bot.get_guild(constants.Guild.id) + + unverified = pydis.get_role(constants.Roles.unverified) + current_dt = datetime.utcnow() # Discord timestamps are UTC + + # Users to be given the @Unverified role, and those to be kicked, these should be entirely disjoint + for_role, for_kick = set(), set() + + log.debug("Checking verification status of guild members") + for member in pydis.members: + + # Skip all bots and users for which we don't know their join date + # This should be extremely rare, but can happen according to `joined_at` docs + if member.bot or member.joined_at is None: + continue + + # Now we check roles to determine whether this user has already verified + unverified_roles = {unverified, pydis.default_role} # Verified users have at least one more role + if set(member.roles) - unverified_roles: + continue + + # At this point, we know that `member` is an unverified user, and we will decide what + # to do with them based on time passed since their join date + since_join = current_dt - member.joined_at + + if since_join > timedelta(days=KICKED_AFTER): + for_kick.add(member) # User should be removed from the guild + + elif since_join > timedelta(days=UNVERIFIED_AFTER) and unverified not in member.roles: + for_role.add(member) # User should be given the @Unverified role + + log.debug(f"{len(for_role)} users will be given the {unverified} role, {len(for_kick)} users will be kicked") + n_kicks = await self._kick_members(for_kick) + n_roles = await self._give_role(for_role, unverified) + @property def mod_log(self) -> ModLog: """Get currently loaded ModLog cog instance.""" @@ -184,7 +242,7 @@ class Verification(Cog): log.debug(f"{ctx.author} called !unsubscribe. Removing the 'Announcements' role.") await ctx.author.remove_roles( - discord.Object(constants.Roles.announcements), reason="Unsubscribed from announcements" + discord.Object(constants.Roles.announcements), reason="Unsubscribed from announcements" ) log.trace(f"Deleting the message posted by {ctx.author}.") -- cgit v1.2.3 From 6840dbe5539cd5a094c65b2d09ddda227ad2ca30 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 18:35:17 +0200 Subject: Verification: implement `_give_role` helper --- bot/cogs/verification.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index ea4874450..683e60ddb 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -59,8 +59,27 @@ class Verification(Cog): ... async def _give_role(self, members: t.Set[discord.Member], role: discord.Role) -> int: - """Give `role` to all `members`.""" - ... + """ + Give `role` to all `members`. + + Returns the amount of successful requests. Status codes of unsuccessful requests + are logged at info level. + """ + log.info(f"Assigning {role} role to {len(members)} members (not verified after {UNVERIFIED_AFTER} days)") + n_success, bad_statuses = 0, set() + + for member in members: + try: + await member.add_roles(role, reason=f"User has not verified in {UNVERIFIED_AFTER} days") + except discord.HTTPException as http_exc: + bad_statuses.add(http_exc.status) + else: + n_success += 1 + + if bad_statuses: + log.info(f"Failed to assign {len(members) - n_success} roles due to following statuses: {bad_statuses}") + + return n_success async def check_users(self) -> None: """ -- cgit v1.2.3 From d807324eef804ec4ea002ef34063046e4fdaeca5 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 18:40:03 +0200 Subject: Verification: implement `_kick_members` helper --- bot/cogs/verification.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 683e60ddb..94c21a568 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -1,4 +1,3 @@ -import asyncio import logging import typing as t from contextlib import suppress @@ -55,8 +54,27 @@ class Verification(Cog): self.bot = bot async def _kick_members(self, members: t.Set[discord.Member]) -> int: - """Kick `members` from the PyDis guild.""" - ... + """ + Kick `members` from the PyDis guild. + + Note that this is a potentially destructive operation. Returns the amount of successful + requests. Failed requests are logged at info level. + """ + log.info(f"Kicking {len(members)} members from the guild (not verified after {KICKED_AFTER} days)") + n_kicked, bad_statuses = 0, set() + + for member in members: + try: + await member.kick(reason=f"User has not verified in {KICKED_AFTER} days") + except discord.HTTPException as http_exc: + bad_statuses.add(http_exc.status) + else: + n_kicked += 1 + + if bad_statuses: + log.info(f"Failed to kick {len(members) - n_kicked} members due to following statuses: {bad_statuses}") + + return n_kicked async def _give_role(self, members: t.Set[discord.Member], role: discord.Role) -> int: """ -- cgit v1.2.3 From 59f8ec77fa25519d1bc81052af7c2cc6460cedad Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 18:42:45 +0200 Subject: Verification: implement `_verify_kick` helper This will be used to guard the call to `_kick_members`. --- bot/cogs/verification.py | 63 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 94c21a568..85a0e3ec4 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -1,3 +1,4 @@ +import asyncio import logging import typing as t from contextlib import suppress @@ -44,6 +45,11 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! UNVERIFIED_AFTER = 3 # Amount of days after which non-Developers receive the @Unverified role KICKED_AFTER = 30 # Amount of days after which non-Developers get kicked from the guild +# Number in range [0, 1] determining the percentage of unverified users that are safe +# to be kicked from the guild in one batch, any larger amount will require staff confirmation, +# set this to 0 to require explicit approval for batches of any size +KICK_CONFIRMATION_THRESHOLD = 0 + BOT_MESSAGE_DELETE_DELAY = 10 @@ -53,6 +59,63 @@ class Verification(Cog): def __init__(self, bot: Bot): self.bot = bot + async def _verify_kick(self, n_members: int) -> bool: + """ + Determine whether `n_members` is a reasonable amount of members to kick. + + First, `n_members` is checked against the size of the PyDis guild. If `n_members` are + more than `KICK_CONFIRMATION_THRESHOLD` of the guild, the operation must be confirmed + by staff in #core-dev. Otherwise, the operation is seen as safe. + """ + log.debug(f"Checking whether {n_members} members are safe to kick") + + await self.bot.wait_until_guild_available() # Ensure cache is populated before we grab the guild + pydis = self.bot.get_guild(constants.Guild.id) + + percentage = n_members / len(pydis.members) + if percentage < KICK_CONFIRMATION_THRESHOLD: + log.debug(f"Kicking {percentage:.2%} of the guild's population is seen as safe") + return True + + # Since `n_members` is a suspiciously large number, we will ask for confirmation + log.debug("Amount of users is too large, requesting staff confirmation") + + core_devs = pydis.get_channel(constants.Channels.dev_core) + confirmation_msg = await core_devs.send( + f"Verification determined that `{n_members}` members should be kicked as they haven't verified in " + f"`{KICKED_AFTER}` days. This is `{percentage:.2%}` of the guild's population. Proceed?" + ) + + options = (constants.Emojis.incident_actioned, constants.Emojis.incident_unactioned) + for option in options: + await confirmation_msg.add_reaction(option) + + def check(reaction: discord.Reaction, user: discord.User) -> bool: + """Check whether `reaction` is a valid reaction to `confirmation_msg`.""" + return ( + reaction.message.id == confirmation_msg.id # Reacted to `confirmation_msg` + and str(reaction.emoji) in options # With one of `options` + and not user.bot # By a human + ) + + timeout = 60 * 5 # Seconds, i.e. 5 minutes + try: + choice, _ = await self.bot.wait_for("reaction_add", check=check, timeout=timeout) + except asyncio.TimeoutError: + log.debug("Staff prompt not answered, aborting operation") + return False + finally: + await confirmation_msg.clear_reactions() + + result = str(choice) == constants.Emojis.incident_actioned + log.debug(f"Received answer: {choice}, result: {result}") + + # Edit the prompt message to reflect the final choice + await confirmation_msg.edit( + content=f"Request to kick `{n_members}` members was {'authorized' if result else 'denied'}!" + ) + return result + async def _kick_members(self, members: t.Set[discord.Member]) -> int: """ Kick `members` from the PyDis guild. -- cgit v1.2.3 From 56f9e84b3bfa07eab4f4623e861cdefada92cdce Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 19:12:31 +0200 Subject: Verification: repurpose & rename `_check_users` Let's only use this function to check on the guild status. It can be exposed via a command in the future. Name adjusted to be more accurate w.r.t. Discord terminology. --- bot/cogs/verification.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 85a0e3ec4..4a9983ac8 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -162,16 +162,15 @@ class Verification(Cog): return n_success - async def check_users(self) -> None: + async def _check_members(self) -> t.Tuple[t.Set[discord.Member], t.Set[discord.Member]]: """ - Periodically check in on the verification status of PyDis members. + Check in on the verification status of PyDis members. - This coroutine performs two actions: - * Find members who have not verified for `UNVERIFIED_AFTER` and give them the @Unverified role - * Find members who have not verified for `KICKED_AFTER` and kick them from the guild + This coroutine finds two sets of users: + * Not verified after `UNVERIFIED_AFTER` days, should be given the @Unverified role + * Not verified after `KICKED_AFTER` days, should be kicked from the guild - Within the body of this coroutine, we only select the members for each action. The work is then - delegated to `_kick_members` and `_give_role`. After each run, a report is sent via modlog. + These sets are always disjoint, i.e. share no common members. """ await self.bot.wait_until_guild_available() # Ensure cache is ready pydis = self.bot.get_guild(constants.Guild.id) @@ -205,9 +204,8 @@ class Verification(Cog): elif since_join > timedelta(days=UNVERIFIED_AFTER) and unverified not in member.roles: for_role.add(member) # User should be given the @Unverified role - log.debug(f"{len(for_role)} users will be given the {unverified} role, {len(for_kick)} users will be kicked") - n_kicks = await self._kick_members(for_kick) - n_roles = await self._give_role(for_role, unverified) + log.debug(f"Found {len(for_role)} users for {unverified} role, {len(for_kick)} users to be kicked") + return for_role, for_kick @property def mod_log(self) -> ModLog: -- cgit v1.2.3 From 4b1100500681d1f4b670f91fe0566e4e85c8371b Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 19:33:42 +0200 Subject: Verification: create task to update unverified members --- bot/cogs/verification.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 4a9983ac8..cc8d8eb7d 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -5,6 +5,7 @@ from contextlib import suppress from datetime import datetime, timedelta import discord +from discord.ext import tasks from discord.ext.commands import Cog, Context, command from bot import constants @@ -207,6 +208,42 @@ class Verification(Cog): log.debug(f"Found {len(for_role)} users for {unverified} role, {len(for_kick)} users to be kicked") return for_role, for_kick + @tasks.loop(minutes=30) + async def update_unverified_members(self) -> None: + """ + Periodically call `_check_members` and update unverified members accordingly. + + After each run, a summary will be sent to the modlog channel. If a suspiciously high + amount of members to be kicked is found, the operation is guarded by `_verify_kick`. + """ + log.info("Updating unverified guild members") + + await self.bot.wait_until_guild_available() + unverified = self.bot.get_guild(constants.Guild.id).get_role(constants.Roles.unverified) + + for_role, for_kick = await self._check_members() + + if not for_role: + role_report = f"Found no users to be assigned the {unverified.mention} role." + else: + n_roles = await self._give_role(for_role, unverified) + role_report = f"Assigned {unverified.mention} role to `{n_roles}`/`{len(for_role)}` members." + + if not for_kick: + kick_report = "Found no users to be kicked." + elif not await self._verify_kick(len(for_kick)): + kick_report = f"Not authorized to kick `{len(for_kick)}` members." + else: + n_kicks = await self._kick_members(for_kick) + kick_report = f"Kicked `{n_kicks}`/`{len(for_kick)}` members from the guild." + + await self.mod_log.send_log_message( + icon_url=self.bot.user.avatar_url, + colour=discord.Colour.blurple(), + title="Verification system", + text=f"{kick_report}\n{role_report}", + ) + @property def mod_log(self) -> ModLog: """Get currently loaded ModLog cog instance.""" -- cgit v1.2.3 From 1305380f2552622cd51e1e22dded80ed2791af44 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 19:41:43 +0200 Subject: Verification: add region comments & move property to top Cog is getting large so let's allow collapsing related bits. --- bot/cogs/verification.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index cc8d8eb7d..951736761 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -60,6 +60,13 @@ class Verification(Cog): def __init__(self, bot: Bot): self.bot = bot + @property + def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + # region: automatically update unverified users + async def _verify_kick(self, n_members: int) -> bool: """ Determine whether `n_members` is a reasonable amount of members to kick. @@ -244,10 +251,8 @@ class Verification(Cog): text=f"{kick_report}\n{role_report}", ) - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") + # endregion + # region: listeners @Cog.listener() async def on_member_join(self, member: discord.Member) -> None: @@ -319,6 +324,9 @@ class Verification(Cog): with suppress(discord.NotFound): await ctx.message.delete() + # endregion + # region: accept and subscribe commands + @command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True) @without_role(constants.Roles.verified) @in_whitelist(channels=(constants.Channels.verification,)) @@ -386,6 +394,9 @@ class Verification(Cog): f"{ctx.author.mention} Unsubscribed from <#{constants.Channels.announcements}> notifications." ) + # endregion + # region: miscellaneous + # This cannot be static (must have a __func__ attribute). async def cog_command_error(self, ctx: Context, error: Exception) -> None: """Check for & ignore any InWhitelistCheckFailure.""" @@ -400,6 +411,8 @@ class Verification(Cog): else: return True + # endregion + def setup(bot: Bot) -> None: """Load the Verification cog.""" -- cgit v1.2.3 From 18f2f1b8817f0209922112a0576b9b0377c2958d Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 19:44:17 +0200 Subject: Verification: schedule member update task Turns out that it's necessary to cancel the task manually. Otherwise, duplicate tasks can be running concurrently should the extension be reloaded. --- bot/cogs/verification.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 951736761..0534e8d1e 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -57,9 +57,20 @@ BOT_MESSAGE_DELETE_DELAY = 10 class Verification(Cog): """User verification and role self-management.""" - def __init__(self, bot: Bot): + def __init__(self, bot: Bot) -> None: + """Start `update_unverified_members` task.""" self.bot = bot + self.update_unverified_members.start() + + def cog_unload(self) -> None: + """ + Kill `update_unverified_members` task. + + This is necessary, the task is not automatically cancelled on cog unload. + """ + self.update_unverified_members.cancel() + @property def mod_log(self) -> ModLog: """Get currently loaded ModLog cog instance.""" -- cgit v1.2.3 From ef0e2049b64a0ba61878161d4ac7edb6015acbc2 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 20:07:24 +0200 Subject: Verification: make authorization message ping core devs --- bot/cogs/verification.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 0534e8d1e..803cb055b 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -101,8 +101,9 @@ class Verification(Cog): core_devs = pydis.get_channel(constants.Channels.dev_core) confirmation_msg = await core_devs.send( - f"Verification determined that `{n_members}` members should be kicked as they haven't verified in " - f"`{KICKED_AFTER}` days. This is `{percentage:.2%}` of the guild's population. Proceed?" + f"<@&{constants.Roles.core_developers}> Verification determined that `{n_members}` members should " + f"be kicked as they haven't verified in `{KICKED_AFTER}` days. This is `{percentage:.2%}` of the " + f"guild's population. Proceed?" ) options = (constants.Emojis.incident_actioned, constants.Emojis.incident_unactioned) -- cgit v1.2.3 From f4721ba580c5d47d0cd5fb5beb60e6af54098244 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 20:11:01 +0200 Subject: Verification: move time constants above messages Allows referencing the constants within the message bodies. --- bot/cogs/verification.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 803cb055b..7244d041d 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -16,6 +16,16 @@ from bot.utils.checks import InWhitelistCheckFailure, without_role_check log = logging.getLogger(__name__) +UNVERIFIED_AFTER = 3 # Amount of days after which non-Developers receive the @Unverified role +KICKED_AFTER = 30 # Amount of days after which non-Developers get kicked from the guild + +# Number in range [0, 1] determining the percentage of unverified users that are safe +# to be kicked from the guild in one batch, any larger amount will require staff confirmation, +# set this to 0 to require explicit approval for batches of any size +KICK_CONFIRMATION_THRESHOLD = 0 + +BOT_MESSAGE_DELETE_DELAY = 10 + ON_JOIN_MESSAGE = f""" Hello! Welcome to Python Discord! @@ -43,16 +53,6 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! <#{constants.Channels.bot_commands}>. """ -UNVERIFIED_AFTER = 3 # Amount of days after which non-Developers receive the @Unverified role -KICKED_AFTER = 30 # Amount of days after which non-Developers get kicked from the guild - -# Number in range [0, 1] determining the percentage of unverified users that are safe -# to be kicked from the guild in one batch, any larger amount will require staff confirmation, -# set this to 0 to require explicit approval for batches of any size -KICK_CONFIRMATION_THRESHOLD = 0 - -BOT_MESSAGE_DELETE_DELAY = 10 - class Verification(Cog): """User verification and role self-management.""" -- cgit v1.2.3 From e2a035cb75f049ed9deae0c88553cf5cece538e8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 4 Aug 2020 12:11:35 -0700 Subject: Filtering: ignore webhooks for nickname filter Fixes #1027 --- bot/cogs/filtering.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 64afd184d..cdad1d01d 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -120,7 +120,10 @@ class Filtering(Cog): async def on_message(self, msg: Message) -> None: """Invoke message filter for new messages.""" await self._filter_message(msg) - await self.check_bad_words_in_name(msg.author) + + # Ignore webhook messages. + if msg.webhook_id is None: + await self.check_bad_words_in_name(msg.author) @Cog.listener() async def on_message_edit(self, before: Message, after: Message) -> None: -- cgit v1.2.3 From 1344d3d6be3e6d244207996784987db5e48523a6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 4 Aug 2020 12:20:52 -0700 Subject: Utils: show error message for long poll titles Embeds have a maximum length of 256 for titles. Fixes #1079 Fixes BOT-7Q --- bot/cogs/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 91c6cb36e..d96abbd5a 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -232,6 +232,8 @@ class Utils(Cog): A maximum of 20 options can be provided, as Discord supports a max of 20 reactions on a single message. """ + if len(title) > 256: + raise BadArgument("The title cannot be longer than 256 characters.") if len(options) < 2: raise BadArgument("Please provide at least 2 options.") if len(options) > 20: -- cgit v1.2.3 From f553785858e70ff78c6fef5a5a4fa3e75c09a55e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 4 Aug 2020 12:32:44 -0700 Subject: HelpChannels: move unpinning to separate function --- bot/cogs/help_channels.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 1be980472..61e8d4384 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -551,18 +551,6 @@ class HelpChannels(commands.Cog): A caller argument is provided for metrics. """ - msg_id = await self.question_messages.pop(channel.id) - - try: - await self.bot.http.unpin_message(channel.id, msg_id) - except discord.HTTPException as e: - if e.code == 10008: - log.trace(f"Message {msg_id} don't exist, can't unpin.") - else: - log.warn(f"Got unexpected status {e.code} when unpinning message {msg_id}: {e.text}") - else: - log.trace(f"Unpinned message {msg_id}.") - log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") await self.move_to_bottom_position( @@ -587,6 +575,8 @@ class HelpChannels(commands.Cog): embed = discord.Embed(description=DORMANT_MSG) await channel.send(embed=embed) + await self.unpin(channel) + log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.") self.channel_queue.put_nowait(channel) self.report_stats() @@ -863,6 +853,20 @@ class HelpChannels(commands.Cog): log.trace(f"Channel #{channel} ({channel_id}) retrieved.") return channel + async def unpin(self, channel: discord.TextChannel) -> None: + """Unpin the initial question message sent in `channel`.""" + msg_id = await self.question_messages.pop(channel.id) + + try: + await self.bot.http.unpin_message(channel.id, msg_id) + except discord.HTTPException as e: + if e.code == 10008: + log.trace(f"Message {msg_id} don't exist, can't unpin.") + else: + log.warn(f"Got unexpected status {e.code} when unpinning message {msg_id}: {e.text}") + else: + log.trace(f"Unpinned message {msg_id}.") + async def wait_for_dormant_channel(self) -> discord.TextChannel: """Wait for a dormant channel to become available in the queue and return it.""" log.trace("Waiting for a dormant channel.") -- cgit v1.2.3 From 9b9a4390111c1a87e0fff87eae134a0745c26345 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 4 Aug 2020 12:36:14 -0700 Subject: HelpChannels: add more detail to unpin log messages --- bot/cogs/help_channels.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 61e8d4384..e281615c2 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -855,17 +855,21 @@ class HelpChannels(commands.Cog): async def unpin(self, channel: discord.TextChannel) -> None: """Unpin the initial question message sent in `channel`.""" + channel_str = f"#{channel} ({channel.id})" + msg_id = await self.question_messages.pop(channel.id) try: await self.bot.http.unpin_message(channel.id, msg_id) except discord.HTTPException as e: if e.code == 10008: - log.trace(f"Message {msg_id} don't exist, can't unpin.") + log.debug(f"Message {msg_id} in {channel_str} doesn't exist; can't unpin.") else: - log.warn(f"Got unexpected status {e.code} when unpinning message {msg_id}: {e.text}") + log.exception( + f"Error unpinning message {msg_id} in {channel_str}: {e.status} ({e.code})" + ) else: - log.trace(f"Unpinned message {msg_id}.") + log.trace(f"Unpinned message {msg_id} in {channel_str}.") async def wait_for_dormant_channel(self) -> discord.TextChannel: """Wait for a dormant channel to become available in the queue and return it.""" -- cgit v1.2.3 From f101f5608dd840ae79db353b562a7c2f800533b2 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 20:12:48 +0200 Subject: Verification: add reminder ping message & frequency --- bot/cogs/verification.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 7244d041d..a01c25010 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -53,6 +53,17 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! <#{constants.Channels.bot_commands}>. """ +REMINDER_MESSAGE = f""" +<@&{constants.Roles.unverified}> + +Welcome to Python Discord! Please read the documents mentioned above and type `!accept` to gain permissions \ +to send messages in the community! + +You will be kicked if you don't verify within `{KICKED_AFTER}` days. +""" + +REMINDER_FREQUENCY = 28 # Hours to wait between sending `REMINDER_MESSAGE` + class Verification(Cog): """User verification and role self-management.""" -- cgit v1.2.3 From a8a583068e0ecacd9f2279a4e24fea0f5920fb51 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 20:13:59 +0200 Subject: Verification: comment message uses --- bot/cogs/verification.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index a01c25010..3502fe5b5 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -26,6 +26,7 @@ KICK_CONFIRMATION_THRESHOLD = 0 BOT_MESSAGE_DELETE_DELAY = 10 +# Sent via DMs once user joins the guild ON_JOIN_MESSAGE = f""" Hello! Welcome to Python Discord! @@ -33,6 +34,7 @@ In order to send messages, you first have to accept our rules. To do so, please <#{constants.Channels.verification}>. Thank you! """ +# Sent via DMs once user verifies VERIFIED_MESSAGE = f""" Thanks for verifying yourself! @@ -53,6 +55,7 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! <#{constants.Channels.bot_commands}>. """ +# Sent periodically in the verification channel REMINDER_MESSAGE = f""" <@&{constants.Roles.unverified}> -- cgit v1.2.3 From b1d761cecf5612d49de47c50994e12ab45b20e5e Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 20:19:00 +0200 Subject: Verification: add reminder cache --- bot/cogs/verification.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 3502fe5b5..e32224554 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -13,6 +13,7 @@ from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.decorators import in_whitelist, without_role from bot.utils.checks import InWhitelistCheckFailure, without_role_check +from bot.utils.redis_cache import RedisCache log = logging.getLogger(__name__) @@ -71,6 +72,10 @@ REMINDER_FREQUENCY = 28 # Hours to wait between sending `REMINDER_MESSAGE` class Verification(Cog): """User verification and role self-management.""" + # Cache last sent `REMINDER_MESSAGE` id + # RedisCache[str, discord.Message.id] + reminder_cache = RedisCache() + def __init__(self, bot: Bot) -> None: """Start `update_unverified_members` task.""" self.bot = bot -- cgit v1.2.3 From 39d7b32b258def7f9fcf01bebb4f82013ae2de76 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 21:26:47 +0200 Subject: Verification: ignore verification reminder message event --- bot/cogs/verification.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index e32224554..ca7631db2 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -301,6 +301,9 @@ class Verification(Cog): if message.channel.id != constants.Channels.verification: return # Only listen for #checkpoint messages + if message.content == REMINDER_MESSAGE.strip(): + return # Ignore bots own verification reminder + if message.author.bot: # They're a bot, delete their message after the delay. await message.delete(delay=BOT_MESSAGE_DELETE_DELAY) -- cgit v1.2.3 From 6f31a1141b513dd6031949467e5409df0d6a3181 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 4 Aug 2020 12:44:18 -0700 Subject: HelpChannels: don't unpin message if ID is None Fixes #1082 Fixes BOT-7G --- bot/cogs/help_channels.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index e281615c2..5e09e0a88 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -858,6 +858,9 @@ class HelpChannels(commands.Cog): channel_str = f"#{channel} ({channel.id})" msg_id = await self.question_messages.pop(channel.id) + if msg_id is None: + log.debug(f"{channel_str} doesn't have a message pinned.") + return try: await self.bot.http.unpin_message(channel.id, msg_id) -- cgit v1.2.3 From a58b4e121eabeb85aeba5d778064f772f049e21b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 4 Aug 2020 13:04:31 -0700 Subject: HelpChannels: create a generic function to handle pin errors This can be used for both pinning and unpinning messages. The error handling code was largely similar between them. --- bot/cogs/help_channels.py | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 5e09e0a88..b452cc574 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -853,26 +853,41 @@ class HelpChannels(commands.Cog): log.trace(f"Channel #{channel} ({channel_id}) retrieved.") return channel - async def unpin(self, channel: discord.TextChannel) -> None: - """Unpin the initial question message sent in `channel`.""" - channel_str = f"#{channel} ({channel.id})" + async def pin_wrapper(self, msg_id: int, channel: discord.TextChannel, *, pin: bool) -> bool: + """ + Pin message `msg_id` in `channel` if `pin` is True or unpin if it's False. - msg_id = await self.question_messages.pop(channel.id) - if msg_id is None: - log.debug(f"{channel_str} doesn't have a message pinned.") - return + Return True if successful and False otherwise. + """ + channel_str = f"#{channel} ({channel.id})" + if pin: + func = self.bot.http.pin_message + verb = "pin" + else: + func = self.bot.http.unpin_message + verb = "unpin" try: - await self.bot.http.unpin_message(channel.id, msg_id) + await func(channel.id, msg_id) except discord.HTTPException as e: if e.code == 10008: - log.debug(f"Message {msg_id} in {channel_str} doesn't exist; can't unpin.") + log.debug(f"Message {msg_id} in {channel_str} doesn't exist; can't {verb}.") else: log.exception( - f"Error unpinning message {msg_id} in {channel_str}: {e.status} ({e.code})" + f"Error {verb}ning message {msg_id} in {channel_str}: {e.status} ({e.code})" ) + return False + else: + log.trace(f"{verb.capitalize()}ned message {msg_id} in {channel_str}.") + return True + + async def unpin(self, channel: discord.TextChannel) -> None: + """Unpin the initial question message sent in `channel`.""" + msg_id = await self.question_messages.pop(channel.id) + if msg_id is None: + log.debug(f"#{channel} ({channel.id}) doesn't have a message pinned.") else: - log.trace(f"Unpinned message {msg_id} in {channel_str}.") + await self.pin_wrapper(msg_id, channel, pin=False) async def wait_for_dormant_channel(self) -> discord.TextChannel: """Wait for a dormant channel to become available in the queue and return it.""" -- cgit v1.2.3 From 4b7f19287c3d212a55276b0862f6a629269eaf92 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 4 Aug 2020 13:09:20 -0700 Subject: HelpChannels: create separate function to pin a message --- bot/cogs/help_channels.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index b452cc574..d826463af 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -694,15 +694,8 @@ class HelpChannels(commands.Cog): log.info(f"Channel #{channel} was claimed by `{message.author.id}`.") await self.move_to_in_use(channel) await self.revoke_send_permissions(message.author) - # Pin message for better access and store this to cache - try: - await message.pin() - except discord.NotFound: - log.info(f"Pinning message {message.id} ({channel}) failed because message got deleted.") - except discord.HTTPException as e: - log.info(f"Pinning message {message.id} ({channel.id}) failed with code {e.code}", exc_info=e) - else: - await self.question_messages.set(channel.id, message.id) + + await self.pin(message) # Add user with channel for dormant check. await self.help_channel_claimants.set(channel.id, message.author.id) @@ -881,6 +874,11 @@ class HelpChannels(commands.Cog): log.trace(f"{verb.capitalize()}ned message {msg_id} in {channel_str}.") return True + async def pin(self, message: discord.Message) -> None: + """Pin an initial question `message` and store it in a cache.""" + if await self.pin_wrapper(message.id, message.channel, pin=True): + await self.question_messages.set(message.channel.id, message.id) + async def unpin(self, channel: discord.TextChannel) -> None: """Unpin the initial question message sent in `channel`.""" msg_id = await self.question_messages.pop(channel.id) -- cgit v1.2.3 From 2e46838aa561d93f70351d08ea275fd0c8b95de2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 4 Aug 2020 13:50:19 -0700 Subject: HelpChannels: more accurate empty check The bot's pin message was being picked up as the last message, so the system was not considering the channel empty. --- bot/cogs/help_channels.py | 18 +++++++++++++++--- config-default.yml | 4 ++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index d826463af..5ecf40e54 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -737,9 +737,21 @@ class HelpChannels(commands.Cog): self.scheduler.schedule_later(delay, msg.channel.id, self.move_idle_channel(msg.channel)) async def is_empty(self, channel: discord.TextChannel) -> bool: - """Return True if the most recent message in `channel` is the bot's `AVAILABLE_MSG`.""" - msg = await self.get_last_message(channel) - return self.match_bot_embed(msg, AVAILABLE_MSG) + """Return True if there's an AVAILABLE_MSG and the messages leading up are bot messages.""" + found = False + + # A limit of 100 results in a single API call. + # If AVAILABLE_MSG isn't found within 100 messages, then assume the channel is not empty. + # Not gonna do an extensive search for it cause it's too expensive. + async for msg in channel.history(limit=100): + if not msg.author.bot: + return False + + if self.match_bot_embed(msg, AVAILABLE_MSG): + found = True + break + + return found async def check_cooldowns(self) -> None: """Remove expired cooldowns and re-schedule active ones.""" diff --git a/config-default.yml b/config-default.yml index aacbe170f..4bd90511c 100644 --- a/config-default.yml +++ b/config-default.yml @@ -432,8 +432,8 @@ help_channels: # Allowed duration of inactivity before making a channel dormant idle_minutes: 30 - # Allowed duration of inactivity when question message deleted - # and no one other sent before message making channel dormant. + # Allowed duration of inactivity when channel is empty (due to deleted messages) + # before message making a channel dormant deleted_idle_minutes: 5 # Maximum number of channels to put in the available category -- cgit v1.2.3 From f2779147f1e3c99436c1437c9b405479e498c17f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 4 Aug 2020 13:57:14 -0700 Subject: HelpChannels: add logging to is_empty --- bot/cogs/help_channels.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 5ecf40e54..a13207d20 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -738,6 +738,7 @@ class HelpChannels(commands.Cog): async def is_empty(self, channel: discord.TextChannel) -> bool: """Return True if there's an AVAILABLE_MSG and the messages leading up are bot messages.""" + log.trace(f"Checking if #{channel} ({channel.id}) is empty.") found = False # A limit of 100 results in a single API call. @@ -745,9 +746,11 @@ class HelpChannels(commands.Cog): # Not gonna do an extensive search for it cause it's too expensive. async for msg in channel.history(limit=100): if not msg.author.bot: + log.trace(f"#{channel} ({channel.id}) has a non-bot message.") return False if self.match_bot_embed(msg, AVAILABLE_MSG): + log.trace(f"#{channel} ({channel.id}) has the available message embed.") found = True break -- cgit v1.2.3 From 3e5558a8ccf79dfeb3efbb63d48d807ba67c8377 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 4 Aug 2020 17:31:50 -0700 Subject: Cancel scheduled tasks when cogs unload When cogs reload, they used new Scheduler instances, which aren't aware of previously scheduled tasks. This led to duplicate scheduled tasks when cogs re-scheduled tasks upon initialisation. Fixes #1080 Fixes BOT-7H --- bot/cogs/filtering.py | 4 ++++ bot/cogs/moderation/scheduler.py | 4 ++++ bot/cogs/moderation/silence.py | 3 ++- bot/cogs/reminders.py | 4 ++++ 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 64afd184d..4ec95ad73 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -99,6 +99,10 @@ class Filtering(Cog): self.bot.loop.create_task(self.reschedule_offensive_msg_deletion()) + def cog_unload(self) -> None: + """Cancel scheduled tasks.""" + self.scheduler.cancel_all() + def _get_filterlist_items(self, list_type: str, *, allowed: bool) -> list: """Fetch items from the filter_list_cache.""" return self.bot.filter_list_cache[f"{list_type.upper()}.{allowed}"].keys() diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 601e238c9..75028d851 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -31,6 +31,10 @@ class InfractionScheduler: self.bot.loop.create_task(self.reschedule_infractions(supported_infractions)) + def cog_unload(self) -> None: + """Cancel scheduled tasks.""" + self.scheduler.cancel_all() + @property def mod_log(self) -> ModLog: """Get the currently loaded ModLog cog instance.""" diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index ae4fb7b64..f8a6592bc 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -152,7 +152,8 @@ class Silence(commands.Cog): return False def cog_unload(self) -> None: - """Send alert with silenced channels on unload.""" + """Send alert with silenced channels and cancel scheduled tasks on unload.""" + self.scheduler.cancel_all() if self.muted_channels: channels_string = ''.join(channel.mention for channel in self.muted_channels) message = f"<@&{Roles.moderators}> channels left silenced on cog unload: {channels_string}" diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index b5998cc0e..670493bcf 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -37,6 +37,10 @@ class Reminders(Cog): self.bot.loop.create_task(self.reschedule_reminders()) + def cog_unload(self) -> None: + """Cancel scheduled tasks.""" + self.scheduler.cancel_all() + async def reschedule_reminders(self) -> None: """Get all current reminders from the API and reschedule them.""" await self.bot.wait_until_guild_available() -- cgit v1.2.3 From 61400aabf6d6d30d09f16e91eb43894fa2b56ff7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 4 Aug 2020 17:48:11 -0700 Subject: Source: raise BadArgument for dynamically-created objects Commands, cogs, etc. created via internal eval won't have a source file associated with them, making source retrieval impossible. Fixes #1083 Fixes BOT-7K --- bot/cogs/source.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index f1db745cd..89548613d 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -60,7 +60,11 @@ class BotSource(commands.Cog): await ctx.send(embed=embed) def get_source_link(self, source_item: SourceType) -> Tuple[str, str, Optional[int]]: - """Build GitHub link of source item, return this link, file location and first line number.""" + """ + Build GitHub link of source item, return this link, file location and first line number. + + Raise BadArgument if `source_item` is a dynamically-created object (e.g. via internal eval). + """ if isinstance(source_item, commands.HelpCommand): src = type(source_item) filename = inspect.getsourcefile(src) @@ -78,10 +82,17 @@ class BotSource(commands.Cog): filename = tags_cog._cache[source_item]["location"] else: src = type(source_item) - filename = inspect.getsourcefile(src) + try: + filename = inspect.getsourcefile(src) + except TypeError: + raise commands.BadArgument("Cannot get source for a dynamically-created object.") if not isinstance(source_item, str): - lines, first_line_no = inspect.getsourcelines(src) + try: + lines, first_line_no = inspect.getsourcelines(src) + except OSError: + raise commands.BadArgument("Cannot get source for a dynamically-created object.") + lines_extension = f"#L{first_line_no}-L{first_line_no+len(lines)-1}" else: first_line_no = None -- cgit v1.2.3 From bcb8f27cba8d1413d302d11e38d122f915f96e14 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 4 Aug 2020 17:52:16 -0700 Subject: Source: remove redundant check for help commands The code is identical to the else block and there's no reason for help commands to have an explicit check. --- bot/cogs/source.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 89548613d..205e0ba81 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -65,10 +65,7 @@ class BotSource(commands.Cog): Raise BadArgument if `source_item` is a dynamically-created object (e.g. via internal eval). """ - if isinstance(source_item, commands.HelpCommand): - src = type(source_item) - filename = inspect.getsourcefile(src) - elif isinstance(source_item, commands.Command): + if isinstance(source_item, commands.Command): if source_item.cog_name == "Alias": cmd_name = source_item.callback.__name__.replace("_alias", "") cmd = self.bot.get_command(cmd_name.replace("_", " ")) -- cgit v1.2.3 From 59c62162e0e0abad53dfbaad0e197a0fbab2f22f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 4 Aug 2020 18:09:13 -0700 Subject: HelpChannels: use more reliable check for claimed channel Using the channel's category isn't reliable since it may take Discord a while to actually move the channel once it's received a request from the bot. I suppose using redis technically has the same problem, but it should be much faster and less susceptible to lag than Discord. Fixes #1074 --- bot/cogs/help_channels.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 1be980472..975043df9 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -694,7 +694,7 @@ class HelpChannels(commands.Cog): async with self.on_message_lock: log.trace(f"on_message lock acquired for {message.id}.") - if not self.is_in_category(channel, constants.Categories.help_available): + if await self.help_channel_claimants.contains(channel.id): log.debug( f"Message {message.id} will not make #{channel} ({channel.id}) in-use " f"because another message in the channel already triggered that." -- cgit v1.2.3 From 8f548f158e17481245809801b1285b17af279fb4 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 22:12:12 +0200 Subject: Verification: implement unverified role ping task We're making good use of d.py's tasks framework. RedisCache is used to persist the reminder message ids, which can conveniently be converted into timestamps. It is therefore trivial to determine the time to sleep before the first ping. After that, the bot simply pings every n hours. --- bot/cogs/verification.py | 52 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index ca7631db2..42088896d 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -7,6 +7,7 @@ from datetime import datetime, timedelta import discord from discord.ext import tasks from discord.ext.commands import Cog, Context, command +from discord.utils import snowflake_time from bot import constants from bot.bot import Bot @@ -282,6 +283,57 @@ class Verification(Cog): text=f"{kick_report}\n{role_report}", ) + # endregion + # region: periodically ping @Unverified + + @tasks.loop(hours=REMINDER_FREQUENCY) + async def ping_unverified(self) -> None: + """ + Delete latest `REMINDER_MESSAGE` and send it again. + + This utilizes RedisCache to persist the latest reminder message id. + """ + await self.bot.wait_until_guild_available() + verification = self.bot.get_guild(constants.Guild.id).get_channel(constants.Channels.verification) + + last_reminder: t.Optional[int] = await self.reminder_cache.get("last_reminder") + + if last_reminder is not None: + log.trace(f"Found verification reminder message in cache, deleting: {last_reminder}") + + with suppress(discord.HTTPException): # If something goes wrong, just ignore it + await self.bot.http.delete_message(verification.id, last_reminder) + + log.trace("Sending verification reminder") + new_reminder = await verification.send(REMINDER_MESSAGE) + + await self.reminder_cache.set("last_reminder", new_reminder.id) + + @ping_unverified.before_loop + async def _before_first_ping(self) -> None: + """ + Sleep until `REMINDER_MESSAGE` should be sent again. + + If latest reminder is not cached, exit instantly. Otherwise, wait wait until the + configured `REMINDER_FREQUENCY` has passed. + """ + last_reminder: t.Optional[int] = await self.reminder_cache.get("last_reminder") + + if last_reminder is None: + log.trace("Latest verification reminder message not cached, task will not wait") + return + + # Convert cached message id into a timestamp + time_since = datetime.utcnow() - snowflake_time(last_reminder) + log.trace(f"Time since latest verification reminder: {time_since}") + + to_sleep = timedelta(hours=REMINDER_FREQUENCY) - time_since + log.trace(f"Time to sleep until next ping: {to_sleep}") + + # Delta can be negative if `REMINDER_FREQUENCY` has already passed + secs = max(to_sleep.total_seconds(), 0) + await asyncio.sleep(secs) + # endregion # region: listeners -- cgit v1.2.3 From 04db02199c72dc0855da8ac90cb514a750dd1f22 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 22:13:15 +0200 Subject: Verification: schedule ping task --- bot/cogs/verification.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 42088896d..5586be040 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -78,18 +78,20 @@ class Verification(Cog): reminder_cache = RedisCache() def __init__(self, bot: Bot) -> None: - """Start `update_unverified_members` task.""" + """Start internal tasks.""" self.bot = bot self.update_unverified_members.start() + self.ping_unverified.start() def cog_unload(self) -> None: """ - Kill `update_unverified_members` task. + Cancel internal tasks. - This is necessary, the task is not automatically cancelled on cog unload. + This is necessary, as tasks are not automatically cancelled on cog unload. """ self.update_unverified_members.cancel() + self.ping_unverified.cancel() @property def mod_log(self) -> ModLog: -- cgit v1.2.3 From bcd2ef98ab91a48ba7b8769f626ff7beb14db663 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 5 Aug 2020 15:26:54 +0200 Subject: Redis: remove erroneous `_redis` alias If a RedisCache instance was being accessed before bot has created the `redis_cache` instance, the `_redis` alias was being set to None, causing AttributeErrors in lookups. See: #1090 --- bot/utils/redis_cache.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/bot/utils/redis_cache.py b/bot/utils/redis_cache.py index 58cfe1df5..52b689b49 100644 --- a/bot/utils/redis_cache.py +++ b/bot/utils/redis_cache.py @@ -226,7 +226,6 @@ class RedisCache: for attribute in vars(instance).values(): if isinstance(attribute, Bot): self.bot = attribute - self._redis = self.bot.redis_session return self else: error_message = ( @@ -251,7 +250,7 @@ class RedisCache: value = self._value_to_typestring(value) log.trace(f"Setting {key} to {value}.") - await self._redis.hset(self._namespace, key, value) + await self.bot.redis_session.hset(self._namespace, key, value) async def get(self, key: RedisKeyType, default: Optional[RedisValueType] = None) -> Optional[RedisValueType]: """Get an item from the Redis cache.""" @@ -259,7 +258,7 @@ class RedisCache: key = self._key_to_typestring(key) log.trace(f"Attempting to retrieve {key}.") - value = await self._redis.hget(self._namespace, key) + value = await self.bot.redis_session.hget(self._namespace, key) if value is None: log.trace(f"Value not found, returning default value {default}") @@ -281,7 +280,7 @@ class RedisCache: key = self._key_to_typestring(key) log.trace(f"Attempting to delete {key}.") - return await self._redis.hdel(self._namespace, key) + return await self.bot.redis_session.hdel(self._namespace, key) async def contains(self, key: RedisKeyType) -> bool: """ @@ -291,7 +290,7 @@ class RedisCache: """ await self._validate_cache() key = self._key_to_typestring(key) - exists = await self._redis.hexists(self._namespace, key) + exists = await self.bot.redis_session.hexists(self._namespace, key) log.trace(f"Testing if {key} exists in the RedisCache - Result is {exists}") return exists @@ -314,7 +313,7 @@ class RedisCache: """ await self._validate_cache() items = self._dict_from_typestring( - await self._redis.hgetall(self._namespace) + await self.bot.redis_session.hgetall(self._namespace) ).items() log.trace(f"Retrieving all key/value pairs from cache, total of {len(items)} items.") @@ -323,7 +322,7 @@ class RedisCache: async def length(self) -> int: """Return the number of items in the Redis cache.""" await self._validate_cache() - number_of_items = await self._redis.hlen(self._namespace) + number_of_items = await self.bot.redis_session.hlen(self._namespace) log.trace(f"Returning length. Result is {number_of_items}.") return number_of_items @@ -335,7 +334,7 @@ class RedisCache: """Deletes the entire hash from the Redis cache.""" await self._validate_cache() log.trace("Clearing the cache of all key/value pairs.") - await self._redis.delete(self._namespace) + await self.bot.redis_session.delete(self._namespace) async def pop(self, key: RedisKeyType, default: Optional[RedisValueType] = None) -> RedisValueType: """Get the item, remove it from the cache, and provide a default if not found.""" @@ -364,7 +363,7 @@ class RedisCache: """ await self._validate_cache() log.trace(f"Updating the cache with the following items:\n{items}") - await self._redis.hmset_dict(self._namespace, self._dict_to_typestring(items)) + await self.bot.redis_session.hmset_dict(self._namespace, self._dict_to_typestring(items)) async def increment(self, key: RedisKeyType, amount: Optional[int, float] = 1) -> None: """ -- cgit v1.2.3 From 5a7ca92cf5d5ae7c7d4aa7ba086237586832af1a Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 5 Aug 2020 17:27:08 +0200 Subject: Revert "HelpChannels: use more reliable check for claimed channel" This reverts commit 59c62162 --- bot/cogs/help_channels.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 975043df9..1be980472 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -694,7 +694,7 @@ class HelpChannels(commands.Cog): async with self.on_message_lock: log.trace(f"on_message lock acquired for {message.id}.") - if await self.help_channel_claimants.contains(channel.id): + if not self.is_in_category(channel, constants.Categories.help_available): log.debug( f"Message {message.id} will not make #{channel} ({channel.id}) in-use " f"because another message in the channel already triggered that." -- cgit v1.2.3 From 9c76e33fbce15b4c42ca2e3966676bec27cfc2c0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 5 Aug 2020 15:34:58 -0700 Subject: HelpChannels: clear claimant cache when channel goes dormant The claimed channel check in `on_message` relies on the cache being cleared when a channel goes dormant. If it's not cleared, it will think the channel is still in use. --- bot/cogs/help_channels.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 975043df9..5f7bb748c 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -215,9 +215,6 @@ class HelpChannels(commands.Cog): log.trace("close command invoked; checking if the channel is in-use.") if ctx.channel.category == self.in_use_category: if await self.dormant_check(ctx): - - # Remove the claimant and the cooldown role - await self.help_channel_claimants.delete(ctx.channel.id) await self.remove_cooldown_role(ctx.author) # Ignore missing task when cooldown has passed but the channel still isn't dormant. @@ -551,6 +548,7 @@ class HelpChannels(commands.Cog): A caller argument is provided for metrics. """ + await self.help_channel_claimants.delete(channel.id) msg_id = await self.question_messages.pop(channel.id) try: -- cgit v1.2.3 From 3bfb3f09bae0f218a06db5f518496be397ed4b66 Mon Sep 17 00:00:00 2001 From: Dennis Pham Date: Wed, 5 Aug 2020 22:00:59 -0400 Subject: Guild invite regex: Add support for dashes in the invite code --- bot/utils/regex.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/utils/regex.py b/bot/utils/regex.py index d194f93cb..0d2068f90 100644 --- a/bot/utils/regex.py +++ b/bot/utils/regex.py @@ -7,6 +7,6 @@ INVITE_RE = re.compile( r"discord(?:[\.,]|dot)me|" # or discord.me r"discord(?:[\.,]|dot)io" # or discord.io. r")(?:[\/]|slash)" # / or 'slash' - r"([a-zA-Z0-9]+)", # the invite code itself + r"([a-zA-Z0-9\-]+)", # the invite code itself flags=re.IGNORECASE ) -- cgit v1.2.3 From fb042c89cd519a41f39eba1559df58fc31a97832 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 6 Aug 2020 10:53:57 +0200 Subject: Verification: remove unverified role on accept --- bot/cogs/verification.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 5586be040..5bc4f81c1 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -422,6 +422,11 @@ class Verification(Cog): """Accept our rules and gain access to the rest of the server.""" log.debug(f"{ctx.author} called !accept. Assigning the 'Developer' role.") await ctx.author.add_roles(discord.Object(constants.Roles.verified), reason="Accepted the rules") + + if constants.Roles.unverified in [role.id for role in ctx.author.roles]: + log.debug(f"Removing Unverified role from: {ctx.author}") + await ctx.author.remove_roles(discord.Object(constants.Roles.unverified)) + try: await ctx.author.send(VERIFIED_MESSAGE) except discord.Forbidden: -- cgit v1.2.3 From 78c19d2f57a41acce231d6950b45dde0fa8832c0 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 6 Aug 2020 13:04:53 +0200 Subject: Verification: add stats collection --- bot/cogs/verification.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 5bc4f81c1..64ff4d8e6 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -176,6 +176,8 @@ class Verification(Cog): else: n_kicked += 1 + self.bot.stats.incr("verification.kicked", count=n_kicked) + if bad_statuses: log.info(f"Failed to kick {len(members) - n_kicked} members due to following statuses: {bad_statuses}") @@ -415,6 +417,30 @@ class Verification(Cog): # endregion # region: accept and subscribe commands + def _bump_verified_stats(self, verified_member: discord.Member) -> None: + """ + Increment verification stats for `verified_member`. + + Each member falls into one of the three categories: + * Verified within 24 hours after joining + * Does not have @Unverified role yet + * Does have @Unverified role + + Stats for member kicking are handled separately. + """ + if verified_member.joined_at is None: # Docs mention this can happen + return + + if (datetime.utcnow() - verified_member.joined_at) < timedelta(hours=24): + category = "accepted_on_day_one" + elif constants.Roles.unverified not in [role.id for role in verified_member.roles]: + category = "accepted_before_unverified" + else: + category = "accepted_after_unverified" + + log.trace(f"Bumping verification stats in category: {category}") + self.bot.stats.incr(f"verification.{category}") + @command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True) @without_role(constants.Roles.verified) @in_whitelist(channels=(constants.Channels.verification,)) @@ -423,6 +449,8 @@ class Verification(Cog): log.debug(f"{ctx.author} called !accept. Assigning the 'Developer' role.") await ctx.author.add_roles(discord.Object(constants.Roles.verified), reason="Accepted the rules") + self._bump_verified_stats(ctx.author) # This checks for @Unverified so make sure it's not yet removed + if constants.Roles.unverified in [role.id for role in ctx.author.roles]: log.debug(f"Removing Unverified role from: {ctx.author}") await ctx.author.remove_roles(discord.Object(constants.Roles.unverified)) -- cgit v1.2.3 From b1c800c623f90f46c4ecaff8da2269efcd04ee05 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 6 Aug 2020 13:12:06 +0200 Subject: Verification: disable burst shared filter in verification We will begin pinging users in the verification channel, prompting them to join. This can cause a surge of activity that may trigger the filter. A better solution would involve allowing per-filter channel config, but after internal discussion this is seen as unnecessary for now. --- bot/rules/burst_shared.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/bot/rules/burst_shared.py b/bot/rules/burst_shared.py index bbe9271b3..0e66df69c 100644 --- a/bot/rules/burst_shared.py +++ b/bot/rules/burst_shared.py @@ -2,11 +2,20 @@ from typing import Dict, Iterable, List, Optional, Tuple from discord import Member, Message +from bot.constants import Channels + async def apply( last_message: Message, recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - """Detects repeated messages sent by multiple users.""" + """ + Detects repeated messages sent by multiple users. + + This filter never triggers in the verification channel. + """ + if last_message.channel.id == Channels.verification: + return + total_recent = len(recent_messages) if total_recent > config['max']: -- cgit v1.2.3 From 0a9769c4fa8d378fb7949212e8733531e9c1591a Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 6 Aug 2020 13:52:57 +0200 Subject: Verification: enable role pings --- bot/cogs/verification.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 64ff4d8e6..2872e704a 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -69,6 +69,13 @@ You will be kicked if you don't verify within `{KICKED_AFTER}` days. REMINDER_FREQUENCY = 28 # Hours to wait between sending `REMINDER_MESSAGE` +MENTION_CORE_DEVS = discord.AllowedMentions( + everyone=False, roles=[discord.Object(constants.Roles.core_developers)] +) +MENTION_UNVERIFIED = discord.AllowedMentions( + everyone=False, roles=[discord.Object(constants.Roles.unverified)] +) + class Verification(Cog): """User verification and role self-management.""" @@ -125,7 +132,8 @@ class Verification(Cog): confirmation_msg = await core_devs.send( f"<@&{constants.Roles.core_developers}> Verification determined that `{n_members}` members should " f"be kicked as they haven't verified in `{KICKED_AFTER}` days. This is `{percentage:.2%}` of the " - f"guild's population. Proceed?" + f"guild's population. Proceed?", + allowed_mentions=MENTION_CORE_DEVS, ) options = (constants.Emojis.incident_actioned, constants.Emojis.incident_unactioned) @@ -309,7 +317,7 @@ class Verification(Cog): await self.bot.http.delete_message(verification.id, last_reminder) log.trace("Sending verification reminder") - new_reminder = await verification.send(REMINDER_MESSAGE) + new_reminder = await verification.send(REMINDER_MESSAGE, allowed_mentions=MENTION_UNVERIFIED) await self.reminder_cache.set("last_reminder", new_reminder.id) -- cgit v1.2.3 From c9f2a2accea4a380eccec9f14fe389e230144242 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 6 Aug 2020 14:02:27 +0200 Subject: Verification: send DM to kicked members --- bot/cogs/verification.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 2872e704a..ac488497a 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -57,6 +57,12 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! <#{constants.Channels.bot_commands}>. """ +# Sent via DMs to users kicked for failing to verify +KICKED_MESSAGE = f""" +Hi! You have been automatically kicked from Python Discord as you have failed to accept our rules \ +within `{KICKED_AFTER}` days. If this was an accident, please feel free to join again. +""" + # Sent periodically in the verification channel REMINDER_MESSAGE = f""" <@&{constants.Roles.unverified}> @@ -177,6 +183,8 @@ class Verification(Cog): n_kicked, bad_statuses = 0, set() for member in members: + with suppress(discord.Forbidden): + await member.send(KICKED_MESSAGE) # Send message while user is still in guild try: await member.kick(reason=f"User has not verified in {KICKED_AFTER} days") except discord.HTTPException as http_exc: -- cgit v1.2.3 From c7eebaa1b70ad6bdd6a84bd0e980d5ea66f0002f Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 6 Aug 2020 14:15:47 +0200 Subject: Verification: bump confirmation threshold to 1% --- bot/cogs/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index ac488497a..23b61a337 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -24,7 +24,7 @@ KICKED_AFTER = 30 # Amount of days after which non-Developers get kicked from t # Number in range [0, 1] determining the percentage of unverified users that are safe # to be kicked from the guild in one batch, any larger amount will require staff confirmation, # set this to 0 to require explicit approval for batches of any size -KICK_CONFIRMATION_THRESHOLD = 0 +KICK_CONFIRMATION_THRESHOLD = 0.01 # 1% BOT_MESSAGE_DELETE_DELAY = 10 -- cgit v1.2.3 From 92cbc03204a3fde78b919fec9d5f17144d24bd83 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 6 Aug 2020 14:38:57 +0200 Subject: Verification: make on-join message more accurate It now explains that new users can only see a limited amount of public channels, and that there will be more once they verify. Co-authored-by: Sebastiaan Zeeff --- bot/cogs/verification.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 23b61a337..ff4b358c7 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -32,8 +32,10 @@ BOT_MESSAGE_DELETE_DELAY = 10 ON_JOIN_MESSAGE = f""" Hello! Welcome to Python Discord! -In order to send messages, you first have to accept our rules. To do so, please visit \ -<#{constants.Channels.verification}>. Thank you! +As a new user, you have read-only access to a few select channels to give you a taste of what our server is like. + +In order to see the rest of the channels and to send messages, you first have to accept our rules. To do so, \ +please visit <#{constants.Channels.verification}>. Thank you! """ # Sent via DMs once user verifies -- cgit v1.2.3 From 16eec3d2d69af5178b03fb574b0f277dbcf1dea8 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 6 Aug 2020 18:17:38 +0200 Subject: Verification: extend cog docstring --- bot/cogs/verification.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index ff4b358c7..963a2369e 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -86,7 +86,23 @@ MENTION_UNVERIFIED = discord.AllowedMentions( class Verification(Cog): - """User verification and role self-management.""" + """ + User verification and role management. + + There are two internal tasks in this cog: + + * `update_unverified_members` + * Unverified members are given the @Unverified role after `UNVERIFIED_AFTER` days + * Unverified members are kicked after `UNVERIFIED_AFTER` days + + * `ping_unverified` + * Periodically ping the @Unverified role in the verification channel + + Statistics are collected in the 'verification.' namespace. + + Additionally, this cog offers the !accept, !subscribe and !unsubscribe commands, + and keeps the verification channel clean by deleting messages. + """ # Cache last sent `REMINDER_MESSAGE` id # RedisCache[str, discord.Message.id] -- cgit v1.2.3 From 673daebe463995de9f53361b3294ad5e496be476 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 6 Aug 2020 11:29:08 -0700 Subject: Deps: update discord.py to 1.4.0 It was released on PyPI. No longer need to clone via git. --- Dockerfile | 5 --- Pipfile | 2 +- Pipfile.lock | 123 ++++++++++++++++++++++++----------------------------------- 3 files changed, 51 insertions(+), 79 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0b1674e7a..06a538b2a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,11 +6,6 @@ ENV PIP_NO_CACHE_DIR=false \ PIPENV_IGNORE_VIRTUALENVS=1 \ PIPENV_NOSPIN=1 -RUN apt-get -y update \ - && apt-get install -y \ - git \ - && rm -rf /var/lib/apt/lists/* - # Install pipenv RUN pip install -U pipenv diff --git a/Pipfile b/Pipfile index 4db8a238b..6fff2223e 100644 --- a/Pipfile +++ b/Pipfile @@ -12,7 +12,7 @@ beautifulsoup4 = "~=4.9" colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"} coloredlogs = "~=14.0" deepdiff = "~=4.0" -discord-py = {git = "https://github.com/Rapptz/discord.py.git",ref = "0bc15fa130b8f01fe2d67446a2184d474b0d0ba7"} +discord.py = "~=1.4.0" fakeredis = "~=1.4" feedparser = "~=5.2" fuzzywuzzy = "~=0.17" diff --git a/Pipfile.lock b/Pipfile.lock index c8cd96d3d..50ddd478c 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "eab4852974d26bd2c10362540c3e01d34af62446cb4e1915ec9a0bf2bddf4d94" + "sha256": "1905fd7eb15074ddbf04f2177b6cdd65edc4c74cb5fcbf4e6ca08ef649ba8a3c" }, "pipfile-spec": 6, "requires": { @@ -60,11 +60,11 @@ }, "aiormq": { "hashes": [ - "sha256:41a9d4eb17db805f30ed172f3f609fe0c2b16657fb15b1b67df19d251dd93c0d", - "sha256:7c19477a9450824cb79f9949fd238f4148e2c0dca67756a2868863c387209f04" + "sha256:106695a836f19c1af6c46b58e8aac80e00f86c5b3287a3c6483a1ee369cc95c9", + "sha256:9f6dbf6155fe2b7a3d24bf68de97fb812db0fac0a54e96bc1af14ea95078ba7f" ], "markers": "python_version >= '3.6'", - "version": "==3.2.2" + "version": "==3.2.3" }, "alabaster": { "hashes": [ @@ -177,9 +177,22 @@ "index": "pypi", "version": "==4.3.2" }, - "discord-py": { - "git": "https://github.com/Rapptz/discord.py.git", - "ref": "0bc15fa130b8f01fe2d67446a2184d474b0d0ba7" + "discord": { + "hashes": [ + "sha256:9d4debb4a37845543bd4b92cb195bc53a302797333e768e70344222857ff1559", + "sha256:ff6653655e342e7721dfb3f10421345fd852c2a33f2cca912b1c39b3778a9429" + ], + "index": "pypi", + "py": "~=1.4.0", + "version": "==1.0.1" + }, + "discord.py": { + "hashes": [ + "sha256:2b1846bfa382b54f4eace8e437a9f59f185388c5b08749ac0e1bbd98e05bfde5", + "sha256:f3db9531fccc391f51de65cfa46133106a9ba12ff2927aca6c14bffd3b7f17b5" + ], + "markers": "python_full_version >= '3.5.3'", + "version": "==1.4.0" }, "docutils": { "hashes": [ @@ -191,11 +204,11 @@ }, "fakeredis": { "hashes": [ - "sha256:4d170886865a91dbc8b7f8cbd4e5d488f4c5f2f25dfae127f001617bbe9e8f97", - "sha256:647b2593d349d9d4e566c8dadb2e4c71ba35be5bdc4f1f7ac2d565a12a965053" + "sha256:790c85ad0f3b2967aba1f51767021bc59760fcb612159584be018ea7384f7fd2", + "sha256:fdfe06f277092d022c271fcaefdc1f0c8d9bfa8cb15374cae41d66a20bd96d2b" ], "index": "pypi", - "version": "==1.4.1" + "version": "==1.4.2" }, "feedparser": { "hashes": [ @@ -542,11 +555,11 @@ }, "sentry-sdk": { "hashes": [ - "sha256:2de15b13836fa3522815a933bd9c887c77f4868071043349f94f1b896c1bcfb8", - "sha256:38bb09d0277117f76507c8728d9a5156f09a47ac5175bb8072513859d19a593b" + "sha256:21b17d6aa064c0fb703a7c00f77cf6c9c497cf2f83345c28892980a5e742d116", + "sha256:4fc97114c77d005467b9b1a29f042e2bc01923cb683b0ef0bbda46e79fa12532" ], "index": "pypi", - "version": "==0.16.2" + "version": "==0.16.3" }, "six": { "hashes": [ @@ -642,14 +655,6 @@ "index": "pypi", "version": "==3.3.0" }, - "typing-extensions": { - "hashes": [ - "sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5", - "sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae", - "sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392" - ], - "version": "==3.7.4.2" - }, "urllib3": { "hashes": [ "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a", @@ -658,56 +663,28 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", "version": "==1.25.10" }, - "websockets": { - "hashes": [ - "sha256:0e4fb4de42701340bd2353bb2eee45314651caa6ccee80dbd5f5d5978888fed5", - "sha256:1d3f1bf059d04a4e0eb4985a887d49195e15ebabc42364f4eb564b1d065793f5", - "sha256:20891f0dddade307ffddf593c733a3fdb6b83e6f9eef85908113e628fa5a8308", - "sha256:295359a2cc78736737dd88c343cd0747546b2174b5e1adc223824bcaf3e164cb", - "sha256:2db62a9142e88535038a6bcfea70ef9447696ea77891aebb730a333a51ed559a", - "sha256:3762791ab8b38948f0c4d281c8b2ddfa99b7e510e46bd8dfa942a5fff621068c", - "sha256:3db87421956f1b0779a7564915875ba774295cc86e81bc671631379371af1170", - "sha256:3ef56fcc7b1ff90de46ccd5a687bbd13a3180132268c4254fc0fa44ecf4fc422", - "sha256:4f9f7d28ce1d8f1295717c2c25b732c2bc0645db3215cf757551c392177d7cb8", - "sha256:5c01fd846263a75bc8a2b9542606927cfad57e7282965d96b93c387622487485", - "sha256:5c65d2da8c6bce0fca2528f69f44b2f977e06954c8512a952222cea50dad430f", - "sha256:751a556205d8245ff94aeef23546a1113b1dd4f6e4d102ded66c39b99c2ce6c8", - "sha256:7ff46d441db78241f4c6c27b3868c9ae71473fe03341340d2dfdbe8d79310acc", - "sha256:965889d9f0e2a75edd81a07592d0ced54daa5b0785f57dc429c378edbcffe779", - "sha256:9b248ba3dd8a03b1a10b19efe7d4f7fa41d158fdaa95e2cf65af5a7b95a4f989", - "sha256:9bef37ee224e104a413f0780e29adb3e514a5b698aabe0d969a6ba426b8435d1", - "sha256:c1ec8db4fac31850286b7cd3b9c0e1b944204668b8eb721674916d4e28744092", - "sha256:c8a116feafdb1f84607cb3b14aa1418424ae71fee131642fc568d21423b51824", - "sha256:ce85b06a10fc65e6143518b96d3dca27b081a740bae261c2fb20375801a9d56d", - "sha256:d705f8aeecdf3262379644e4b55107a3b55860eb812b673b28d0fbc347a60c55", - "sha256:e898a0863421650f0bebac8ba40840fc02258ef4714cb7e1fd76b6a6354bda36", - "sha256:f8a7bff6e8664afc4e6c28b983845c5bc14965030e3fb98789734d416af77c4b" - ], - "markers": "python_full_version >= '3.6.1'", - "version": "==8.1" - }, "yarl": { "hashes": [ - "sha256:1707230e1ea48ea06a3e20acb4ce05a38d2465bd9566c21f48f6212a88e47536", - "sha256:1f269e8e6676193a94635399a77c9059e1826fb6265c9204c9e5a8ccd36006e1", - "sha256:2657716c1fc998f5f2675c0ee6ce91282e0da0ea9e4a94b584bb1917e11c1559", - "sha256:431faa6858f0ea323714d8b7b4a7da1db2eeb9403607f0eaa3800ab2c5a4b627", - "sha256:5bbcb195da7de57f4508b7508c33f7593e9516e27732d08b9aad8586c7b8c384", - "sha256:5c82f5b1499342339f22c83b97dbe2b8a09e47163fab86cd934a8dd46620e0fb", - "sha256:5d410f69b4f92c5e1e2a8ffb73337cd8a274388c6975091735795588a538e605", - "sha256:66b4f345e9573e004b1af184bc00431145cf5e089a4dcc1351505c1f5750192c", - "sha256:875b2a741ce0208f3b818008a859ab5d0f461e98a32bbdc6af82231a9e761c55", - "sha256:9a3266b047d15e78bba38c8455bf68b391c040231ca5965ef867f7cbbc60bde5", - "sha256:9a592c4aa642249e9bdaf76897d90feeb08118626b363a6be8788a9b300274b5", - "sha256:a1772068401d425e803999dada29a6babf041786e08be5e79ef63c9ecc4c9575", - "sha256:b065a5c3e050395ae563019253cc6c769a50fd82d7fa92d07476273521d56b7c", - "sha256:b325fefd574ebef50e391a1072d1712a60348ca29c183e1d546c9d87fec2cd32", - "sha256:cf5eb664910d759bbae0b76d060d6e21f8af5098242d66c448bbebaf2a7bfa70", - "sha256:f058b6541477022c7b54db37229f87dacf3b565de4f901ff5a0a78556a174fea", - "sha256:f5cfed0766837303f688196aa7002730d62c5cc802d98c6395ea1feb87252727" + "sha256:040b237f58ff7d800e6e0fd89c8439b841f777dd99b4a9cca04d6935564b9409", + "sha256:17668ec6722b1b7a3a05cc0167659f6c95b436d25a36c2d52db0eca7d3f72593", + "sha256:3a584b28086bc93c888a6c2aa5c92ed1ae20932f078c46509a66dce9ea5533f2", + "sha256:4439be27e4eee76c7632c2427ca5e73703151b22cae23e64adb243a9c2f565d8", + "sha256:48e918b05850fffb070a496d2b5f97fc31d15d94ca33d3d08a4f86e26d4e7c5d", + "sha256:9102b59e8337f9874638fcfc9ac3734a0cfadb100e47d55c20d0dc6087fb4692", + "sha256:9b930776c0ae0c691776f4d2891ebc5362af86f152dd0da463a6614074cb1b02", + "sha256:b3b9ad80f8b68519cc3372a6ca85ae02cc5a8807723ac366b53c0f089db19e4a", + "sha256:bc2f976c0e918659f723401c4f834deb8a8e7798a71be4382e024bcc3f7e23a8", + "sha256:c22c75b5f394f3d47105045ea551e08a3e804dc7e01b37800ca35b58f856c3d6", + "sha256:c52ce2883dc193824989a9b97a76ca86ecd1fa7955b14f87bf367a61b6232511", + "sha256:ce584af5de8830d8701b8979b18fcf450cef9a382b1a3c8ef189bedc408faf1e", + "sha256:da456eeec17fa8aa4594d9a9f27c0b1060b6a75f2419fe0c00609587b2695f4a", + "sha256:db6db0f45d2c63ddb1a9d18d1b9b22f308e52c83638c26b422d520a815c4b3fb", + "sha256:df89642981b94e7db5596818499c4b2219028f2a528c9c37cc1de45bf2fd3a3f", + "sha256:f18d68f2be6bf0e89f1521af2b1bb46e66ab0018faafa81d70f358153170a317", + "sha256:f379b7f83f23fe12823085cd6b906edc49df969eb99757f58ff382349a3303c6" ], "markers": "python_version >= '3.5'", - "version": "==1.5.0" + "version": "==1.5.1" } }, "develop": { @@ -728,11 +705,11 @@ }, "cfgv": { "hashes": [ - "sha256:1ccf53320421aeeb915275a196e23b3b8ae87dea8ac6698b1638001d4a486d53", - "sha256:c8e8f552ffcc6194f4e18dd4f68d9aef0c0d58ae7e7be8c82bee3c5e9edfa513" + "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d", + "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1" ], "markers": "python_full_version >= '3.6.1'", - "version": "==3.1.0" + "version": "==3.2.0" }, "coverage": { "hashes": [ @@ -968,11 +945,11 @@ }, "virtualenv": { "hashes": [ - "sha256:688a61d7976d82b92f7906c367e83bb4b3f0af96f8f75bfcd3da95608fe8ac6c", - "sha256:8f582a030156282a9ee9d319984b759a232b07f86048c1d6a9e394afa44e78c8" + "sha256:7b54fd606a1b85f83de49ad8d80dbec08e983a2d2f96685045b262ebc7481ee5", + "sha256:8cd7b2a4850b003a11be2fc213e206419efab41115cc14bca20e69654f2ac08e" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.0.28" + "version": "==20.0.30" } } } -- cgit v1.2.3 From fe50d6457081e0e6ef86d821bcfab81a8a164ca5 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 6 Aug 2020 23:45:53 +0200 Subject: Verification: add command interface for task management Allow checking whether tasks are running, starting them, and stopping them. Currently, the tasks cannot be started or stopped separately. It is not believed that we would need such a level of granularity. Calling `cancel` on a task that isn't running is a no-op. --- bot/cogs/verification.py | 62 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 963a2369e..152118d92 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -6,13 +6,13 @@ from datetime import datetime, timedelta import discord from discord.ext import tasks -from discord.ext.commands import Cog, Context, command +from discord.ext.commands import Cog, Context, command, group from discord.utils import snowflake_time from bot import constants from bot.bot import Bot from bot.cogs.moderation import ModLog -from bot.decorators import in_whitelist, without_role +from bot.decorators import in_whitelist, with_role, without_role from bot.utils.checks import InWhitelistCheckFailure, without_role_check from bot.utils.redis_cache import RedisCache @@ -448,6 +448,64 @@ class Verification(Cog): with suppress(discord.NotFound): await ctx.message.delete() + # endregion + # region: task management commands + + @with_role(*constants.MODERATION_ROLES) + @group(name="verification") + async def verification_group(self, ctx: Context) -> None: + """Manage internal verification tasks.""" + if ctx.invoked_subcommand is None: + await ctx.send_help(ctx.command) + + @verification_group.command(name="status") + async def status_cmd(self, ctx: Context) -> None: + """Check whether verification tasks are running.""" + log.trace("Checking status of verification tasks") + + if self.update_unverified_members.is_running(): + update_status = f"{constants.Emojis.incident_actioned} Member update task is running." + else: + update_status = f"{constants.Emojis.incident_unactioned} Member update task is **not** running." + + mention = f"<@&{constants.Roles.unverified}>" + if self.ping_unverified.is_running(): + ping_status = f"{constants.Emojis.incident_actioned} Ping {mention} is running." + else: + ping_status = f"{constants.Emojis.incident_unactioned} Ping {mention} is **not** running." + + embed = discord.Embed( + title="Verification system", + description=f"{update_status}\n{ping_status}", + colour=discord.Colour.blurple(), + ) + await ctx.send(embed=embed) + + @verification_group.command(name="start") + async def start_cmd(self, ctx: Context) -> None: + """Start verification tasks if they are not already running.""" + log.info("Starting verification tasks") + + if not self.update_unverified_members.is_running(): + self.update_unverified_members.start() + + if not self.ping_unverified.is_running(): + self.ping_unverified.start() + + colour = discord.Colour.blurple() + await ctx.send(embed=discord.Embed(title="Verification system", description="Done. :ok_hand:", colour=colour)) + + @verification_group.command(name="stop", aliases=["kill"]) + async def stop_cmd(self, ctx: Context) -> None: + """Stop verification tasks.""" + log.info("Stopping verification tasks") + + self.update_unverified_members.cancel() + self.ping_unverified.cancel() + + colour = discord.Colour.blurple() + await ctx.send(embed=discord.Embed(title="Verification system", description="Tasks canceled.", colour=colour)) + # endregion # region: accept and subscribe commands -- cgit v1.2.3 From 2d24e4730e0f9678c0d7833c2332c7f0821eb7e2 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 6 Aug 2020 23:48:06 +0200 Subject: Verification: persist task settings in Redis If tasks are stopped manually, they will not automatically restart on cog reload or bot restart. Using `maybe_start_tasks` is necessary because we cannot interface with Redis from a sync context. We're using 1 and 0 because RedisCache does not currently permit bool values due to a typestring conversion bug. --- bot/cogs/verification.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 152118d92..b4dc1f145 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -111,9 +111,7 @@ class Verification(Cog): def __init__(self, bot: Bot) -> None: """Start internal tasks.""" self.bot = bot - - self.update_unverified_members.start() - self.ping_unverified.start() + self.bot.loop.create_task(self.maybe_start_tasks()) def cog_unload(self) -> None: """ @@ -129,6 +127,20 @@ class Verification(Cog): """Get currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") + async def maybe_start_tasks(self) -> None: + """ + Poll Redis to check whether internal tasks should start. + + Redis must be interfaced with from an async function. + """ + log.trace("Checking whether background tasks should begin") + setting: t.Optional[int] = await self.reminder_cache.get("tasks_running") # This can be None if never set + + if setting: + log.trace("Background tasks will be started") + self.update_unverified_members.start() + self.ping_unverified.start() + # region: automatically update unverified users async def _verify_kick(self, n_members: int) -> bool: @@ -492,6 +504,8 @@ class Verification(Cog): if not self.ping_unverified.is_running(): self.ping_unverified.start() + await self.reminder_cache.set("tasks_running", 1) + colour = discord.Colour.blurple() await ctx.send(embed=discord.Embed(title="Verification system", description="Done. :ok_hand:", colour=colour)) @@ -503,6 +517,8 @@ class Verification(Cog): self.update_unverified_members.cancel() self.ping_unverified.cancel() + await self.reminder_cache.set("tasks_running", 0) + colour = discord.Colour.blurple() await ctx.send(embed=discord.Embed(title="Verification system", description="Tasks canceled.", colour=colour)) -- cgit v1.2.3 From 80063705dc2264c1a320100f3620b5a384780699 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 7 Aug 2020 10:21:28 +0200 Subject: Verification: rename cache & document new use --- bot/cogs/verification.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index b4dc1f145..6b245d574 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -100,13 +100,19 @@ class Verification(Cog): Statistics are collected in the 'verification.' namespace. + Moderators+ can use the `verification` command group to start or stop both internal + tasks, if necessary. Settings are persisted in Redis across sessions. + Additionally, this cog offers the !accept, !subscribe and !unsubscribe commands, and keeps the verification channel clean by deleting messages. """ - # Cache last sent `REMINDER_MESSAGE` id - # RedisCache[str, discord.Message.id] - reminder_cache = RedisCache() + # Persist task settings & last sent `REMINDER_MESSAGE` id + # RedisCache[ + # "tasks_running": int (0 or 1), + # "last_reminder": int (discord.Message.id), + # ] + task_cache = RedisCache() def __init__(self, bot: Bot) -> None: """Start internal tasks.""" @@ -134,7 +140,7 @@ class Verification(Cog): Redis must be interfaced with from an async function. """ log.trace("Checking whether background tasks should begin") - setting: t.Optional[int] = await self.reminder_cache.get("tasks_running") # This can be None if never set + setting: t.Optional[int] = await self.task_cache.get("tasks_running") # This can be None if never set if setting: log.trace("Background tasks will be started") @@ -346,7 +352,7 @@ class Verification(Cog): await self.bot.wait_until_guild_available() verification = self.bot.get_guild(constants.Guild.id).get_channel(constants.Channels.verification) - last_reminder: t.Optional[int] = await self.reminder_cache.get("last_reminder") + last_reminder: t.Optional[int] = await self.task_cache.get("last_reminder") if last_reminder is not None: log.trace(f"Found verification reminder message in cache, deleting: {last_reminder}") @@ -357,7 +363,7 @@ class Verification(Cog): log.trace("Sending verification reminder") new_reminder = await verification.send(REMINDER_MESSAGE, allowed_mentions=MENTION_UNVERIFIED) - await self.reminder_cache.set("last_reminder", new_reminder.id) + await self.task_cache.set("last_reminder", new_reminder.id) @ping_unverified.before_loop async def _before_first_ping(self) -> None: @@ -367,7 +373,7 @@ class Verification(Cog): If latest reminder is not cached, exit instantly. Otherwise, wait wait until the configured `REMINDER_FREQUENCY` has passed. """ - last_reminder: t.Optional[int] = await self.reminder_cache.get("last_reminder") + last_reminder: t.Optional[int] = await self.task_cache.get("last_reminder") if last_reminder is None: log.trace("Latest verification reminder message not cached, task will not wait") @@ -504,7 +510,7 @@ class Verification(Cog): if not self.ping_unverified.is_running(): self.ping_unverified.start() - await self.reminder_cache.set("tasks_running", 1) + await self.task_cache.set("tasks_running", 1) colour = discord.Colour.blurple() await ctx.send(embed=discord.Embed(title="Verification system", description="Done. :ok_hand:", colour=colour)) @@ -517,7 +523,7 @@ class Verification(Cog): self.update_unverified_members.cancel() self.ping_unverified.cancel() - await self.reminder_cache.set("tasks_running", 0) + await self.task_cache.set("tasks_running", 0) colour = discord.Colour.blurple() await ctx.send(embed=discord.Embed(title="Verification system", description="Tasks canceled.", colour=colour)) -- cgit v1.2.3 From 6be9f0d24caa792b23d8f93ce9d87e48df3e92a5 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 7 Aug 2020 15:22:56 +0200 Subject: Verification: address member update race condition In an edge case, the `_kick_members` and `_give_role` could act on a member who has verified *after* being marked by `_check_members` as unverified. To address this, we perform one additional check just before sending the request. Testing seems to indicate that the `discord.Member` instance get updates as appropriate, so this should at least reduce the chances of such a race happening to very close to nil. --- bot/cogs/verification.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 6b245d574..ed03b0a14 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -85,6 +85,20 @@ MENTION_UNVERIFIED = discord.AllowedMentions( ) +def is_verified(member: discord.Member) -> bool: + """ + Check whether `member` is considered verified. + + Members are considered verified if they have at least 1 role other than + the default role (@everyone) and the @Unverified role. + """ + unverified_roles = { + member.guild.get_role(constants.Roles.unverified), + member.guild.default_role, + } + return bool(set(member.roles) - unverified_roles) + + class Verification(Cog): """ User verification and role management. @@ -219,6 +233,8 @@ class Verification(Cog): n_kicked, bad_statuses = 0, set() for member in members: + if is_verified(member): # Member could have verified in the meantime + continue with suppress(discord.Forbidden): await member.send(KICKED_MESSAGE) # Send message while user is still in guild try: @@ -246,6 +262,8 @@ class Verification(Cog): n_success, bad_statuses = 0, set() for member in members: + if is_verified(member): # Member could have verified in the meantime + continue try: await member.add_roles(role, reason=f"User has not verified in {UNVERIFIED_AFTER} days") except discord.HTTPException as http_exc: @@ -280,14 +298,9 @@ class Verification(Cog): log.debug("Checking verification status of guild members") for member in pydis.members: - # Skip all bots and users for which we don't know their join date - # This should be extremely rare, but can happen according to `joined_at` docs - if member.bot or member.joined_at is None: - continue - - # Now we check roles to determine whether this user has already verified - unverified_roles = {unverified, pydis.default_role} # Verified users have at least one more role - if set(member.roles) - unverified_roles: + # Skip verified members, bots, and members for which we do not know their join date, + # this should be extremely rare but docs mention that it can happen + if is_verified(member) or member.bot or member.joined_at is None: continue # At this point, we know that `member` is an unverified user, and we will decide what -- cgit v1.2.3 From 806825ec56e13391fecd45ba0e0da6ab365e11ec Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 7 Aug 2020 11:08:12 -0700 Subject: HelpChannels: simplify control flow in is_empty --- bot/cogs/help_channels.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index a13207d20..bdfbf3392 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -739,7 +739,6 @@ class HelpChannels(commands.Cog): async def is_empty(self, channel: discord.TextChannel) -> bool: """Return True if there's an AVAILABLE_MSG and the messages leading up are bot messages.""" log.trace(f"Checking if #{channel} ({channel.id}) is empty.") - found = False # A limit of 100 results in a single API call. # If AVAILABLE_MSG isn't found within 100 messages, then assume the channel is not empty. @@ -751,10 +750,9 @@ class HelpChannels(commands.Cog): if self.match_bot_embed(msg, AVAILABLE_MSG): log.trace(f"#{channel} ({channel.id}) has the available message embed.") - found = True - break + return True - return found + return False async def check_cooldowns(self) -> None: """Remove expired cooldowns and re-schedule active ones.""" -- cgit v1.2.3 From 553b6a9118dc21634d0fd78fdb58f98cb02c3c7f Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 8 Aug 2020 11:02:51 +0200 Subject: Verification: improve `is_verified` check This just reads better. Co-authored-by: MarkKoz --- bot/cogs/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index ed03b0a14..d4064cff7 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -96,7 +96,7 @@ def is_verified(member: discord.Member) -> bool: member.guild.get_role(constants.Roles.unverified), member.guild.default_role, } - return bool(set(member.roles) - unverified_roles) + return len(set(member.roles) - unverified_roles) > 0 class Verification(Cog): -- cgit v1.2.3 From f3c16f77d812be50c2b7bed4c046cd67f3b9b761 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 8 Aug 2020 11:04:24 +0200 Subject: Verification: only take reactions from core devs Co-authored-by: MarkKoz --- bot/cogs/verification.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index d4064cff7..da2f81e2d 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -196,12 +196,14 @@ class Verification(Cog): for option in options: await confirmation_msg.add_reaction(option) + core_dev_ids = [member.id for member in pydis.get_role(constants.Roles.core_developers).members] + def check(reaction: discord.Reaction, user: discord.User) -> bool: """Check whether `reaction` is a valid reaction to `confirmation_msg`.""" return ( reaction.message.id == confirmation_msg.id # Reacted to `confirmation_msg` and str(reaction.emoji) in options # With one of `options` - and not user.bot # By a human + and user.id in core_dev_ids # By a core developer ) timeout = 60 * 5 # Seconds, i.e. 5 minutes -- cgit v1.2.3 From 9b91847950a31f094c92a77974edc19d7766f514 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 8 Aug 2020 11:05:57 +0200 Subject: Verification: widen set type annotation Co-authored-by: MarkKoz --- bot/cogs/verification.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index da2f81e2d..9dc65da1c 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -224,7 +224,7 @@ class Verification(Cog): ) return result - async def _kick_members(self, members: t.Set[discord.Member]) -> int: + async def _kick_members(self, members: t.Collection[discord.Member]) -> int: """ Kick `members` from the PyDis guild. @@ -253,7 +253,7 @@ class Verification(Cog): return n_kicked - async def _give_role(self, members: t.Set[discord.Member], role: discord.Role) -> int: + async def _give_role(self, members: t.Collection[discord.Member], role: discord.Role) -> int: """ Give `role` to all `members`. -- cgit v1.2.3 From 174796a9bf8fcb117f38e8d6dc1a4b17c3849334 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 8 Aug 2020 11:50:50 +0200 Subject: Verification: strip reminder message once and for all Co-authored-by: MarkKoz --- bot/cogs/verification.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 9dc65da1c..a22b91e5d 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -73,7 +73,7 @@ Welcome to Python Discord! Please read the documents mentioned above and type `! to send messages in the community! You will be kicked if you don't verify within `{KICKED_AFTER}` days. -""" +""".strip() REMINDER_FREQUENCY = 28 # Hours to wait between sending `REMINDER_MESSAGE` @@ -424,7 +424,7 @@ class Verification(Cog): if message.channel.id != constants.Channels.verification: return # Only listen for #checkpoint messages - if message.content == REMINDER_MESSAGE.strip(): + if message.content == REMINDER_MESSAGE: return # Ignore bots own verification reminder if message.author.bot: -- cgit v1.2.3 From 286cdccb21ed035d697128c2212d88368cb48e8d Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 8 Aug 2020 13:56:24 +0200 Subject: Verification: improve confirmation message handling Suppress errors coming from Discord when changing the confirmation message in case it gets deleted, or something else goes wrong. This commit also adds either the ok hand or the warning emoji to the edited message content, as with the guild syncer confirmation. Co-authored-by: MarkKoz --- bot/cogs/verification.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index a22b91e5d..cbf2c51c3 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -213,15 +213,21 @@ class Verification(Cog): log.debug("Staff prompt not answered, aborting operation") return False finally: - await confirmation_msg.clear_reactions() + with suppress(discord.HTTPException): + await confirmation_msg.clear_reactions() result = str(choice) == constants.Emojis.incident_actioned log.debug(f"Received answer: {choice}, result: {result}") # Edit the prompt message to reflect the final choice - await confirmation_msg.edit( - content=f"Request to kick `{n_members}` members was {'authorized' if result else 'denied'}!" - ) + if result is True: + result_msg = f":ok_hand: Request to kick `{n_members}` members was authorized!" + else: + result_msg = f":warning: Request to kick `{n_members}` members was denied!" + + with suppress(discord.HTTPException): + await confirmation_msg.edit(content=result_msg) + return result async def _kick_members(self, members: t.Collection[discord.Member]) -> int: -- cgit v1.2.3 From 3cd4c92b1e24c8cfdae8c5c68c19607c62cc01ed Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 8 Aug 2020 18:32:47 +0100 Subject: Remove unnecessary edits during pagination --- bot/pagination.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/bot/pagination.py b/bot/pagination.py index 94c2d7c0c..bab98cacf 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -313,8 +313,6 @@ class LinePaginator(Paginator): log.debug(f"Got first page reaction - changing to page 1/{len(paginator.pages)}") - embed.description = "" - await message.edit(embed=embed) embed.description = paginator.pages[current_page] if footer_text: embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") @@ -328,8 +326,6 @@ class LinePaginator(Paginator): log.debug(f"Got last page reaction - changing to page {current_page + 1}/{len(paginator.pages)}") - embed.description = "" - await message.edit(embed=embed) embed.description = paginator.pages[current_page] if footer_text: embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") @@ -347,8 +343,6 @@ class LinePaginator(Paginator): current_page -= 1 log.debug(f"Got previous page reaction - changing to page {current_page + 1}/{len(paginator.pages)}") - embed.description = "" - await message.edit(embed=embed) embed.description = paginator.pages[current_page] if footer_text: @@ -368,8 +362,6 @@ class LinePaginator(Paginator): current_page += 1 log.debug(f"Got next page reaction - changing to page {current_page + 1}/{len(paginator.pages)}") - embed.description = "" - await message.edit(embed=embed) embed.description = paginator.pages[current_page] if footer_text: @@ -532,8 +524,6 @@ class ImagePaginator(Paginator): reaction_type = "next" # Magic happens here, after page and reaction_type is set - embed.description = "" - await message.edit(embed=embed) embed.description = paginator.pages[current_page] image = paginator.images[current_page] -- cgit v1.2.3 From a36e04b70c3090b128ac80221582f140c196b20f Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 10 Aug 2020 01:54:18 +0200 Subject: Remove unused api endpoint config constants. The constants aren't used anywhere in the bot, and are incompatible with the APIClient. --- bot/constants.py | 14 -------------- config-default.yml | 17 ----------------- 2 files changed, 31 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 9d00eac36..6baa04ec5 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -488,22 +488,8 @@ class URLs(metaclass=YAMLGetter): # Site endpoints site: str site_api: str - site_superstarify_api: str - site_logs_api: str site_logs_view: str - site_reminders_api: str - site_reminders_user_api: str site_schema: str - site_settings_api: str - site_tags_api: str - site_user_api: str - site_user_complete_api: str - site_infractions: str - site_infractions_user: str - site_infractions_type: str - site_infractions_by_id: str - site_infractions_user_type_current: str - site_infractions_user_type: str paste_service: str diff --git a/config-default.yml b/config-default.yml index 4bd90511c..e3ba9fb05 100644 --- a/config-default.yml +++ b/config-default.yml @@ -309,24 +309,7 @@ urls: site_staff: &STAFF !JOIN ["staff.", *DOMAIN] site_schema: &SCHEMA "https://" - site_bigbrother_api: !JOIN [*SCHEMA, *API, "/bot/bigbrother"] - site_docs_api: !JOIN [*SCHEMA, *API, "/bot/docs"] - site_superstarify_api: !JOIN [*SCHEMA, *API, "/bot/superstarify"] - site_infractions: !JOIN [*SCHEMA, *API, "/bot/infractions"] - site_infractions_user: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}"] - site_infractions_type: !JOIN [*SCHEMA, *API, "/bot/infractions/type/{infraction_type}"] - site_infractions_by_id: !JOIN [*SCHEMA, *API, "/bot/infractions/id/{infraction_id}"] - site_infractions_user_type_current: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}/{infraction_type}/current"] - site_infractions_user_type: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}/{infraction_type}"] - site_logs_api: !JOIN [*SCHEMA, *API, "/bot/logs"] site_logs_view: !JOIN [*SCHEMA, *STAFF, "/bot/logs"] - site_off_topic_names_api: !JOIN [*SCHEMA, *API, "/bot/off-topic-names"] - site_reminders_api: !JOIN [*SCHEMA, *API, "/bot/reminders"] - site_reminders_user_api: !JOIN [*SCHEMA, *API, "/bot/reminders/user"] - site_settings_api: !JOIN [*SCHEMA, *API, "/bot/settings"] - site_tags_api: !JOIN [*SCHEMA, *API, "/bot/tags"] - site_user_api: !JOIN [*SCHEMA, *API, "/bot/users"] - site_user_complete_api: !JOIN [*SCHEMA, *API, "/bot/users/complete"] paste_service: !JOIN [*SCHEMA, *PASTE, "/{key}"] # Snekbox -- cgit v1.2.3 From 573154451ed4d330443e4c340fc46ab24e52f852 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 10 Aug 2020 01:58:44 +0200 Subject: Reorder site URL constants. --- bot/constants.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 6baa04ec5..d01dcb0fc 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -485,11 +485,13 @@ class URLs(metaclass=YAMLGetter): bot_avatar: str github_bot_repo: str - # Site endpoints + # Base site vars site: str site_api: str - site_logs_view: str site_schema: str + + # Site endpoints + site_logs_view: str paste_service: str -- cgit v1.2.3 From 5c6d19d335fe39af58d9787434b3a1bd64e22839 Mon Sep 17 00:00:00 2001 From: Dennis Pham Date: Sun, 9 Aug 2020 20:20:35 -0400 Subject: Create kindling-projects tag --- bot/resources/tags/kindling-projects.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 bot/resources/tags/kindling-projects.md diff --git a/bot/resources/tags/kindling-projects.md b/bot/resources/tags/kindling-projects.md new file mode 100644 index 000000000..54ed8c961 --- /dev/null +++ b/bot/resources/tags/kindling-projects.md @@ -0,0 +1,3 @@ +**Kindling Projects** + +The [Kindling projects page](https://nedbatchelder.com/text/kindling.html) on Ned Batchelder's website contains a list of projects and ideas programmers can tackle to build their skills and knowledge. -- cgit v1.2.3 From 5d953b55ef7db247c009d5caae61fa3b1df8ff3e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 10 Aug 2020 11:31:55 -0700 Subject: Concatenate string in one line --- bot/cogs/moderation/modlog.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 724651ecd..5d3055796 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -403,11 +403,10 @@ class ModLog(Cog, name="ModLog"): if member.guild.id != GuildConstant.id: return - message = format_user(member) now = datetime.utcnow() difference = abs(relativedelta(now, member.created_at)) - message += "\n\n**Account age:** " + humanize_delta(difference) + message = format_user(member) + "\n\n**Account age:** " + humanize_delta(difference) if difference.days < 1 and difference.months < 1 and difference.years < 1: # New user account! message = f"{Emojis.new} {message}" -- cgit v1.2.3 From 5051b5aeccd1f33ba34b2dd2af03511343b60efd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 10 Aug 2020 11:34:51 -0700 Subject: Use format_user in token remover test The point of format_user is to have a consistent format across the code base. That should apply to tests too. --- tests/bot/cogs/test_token_remover.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 1c7267f56..5dee6922e 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -9,6 +9,7 @@ from bot import constants from bot.cogs import token_remover from bot.cogs.moderation import ModLog from bot.cogs.token_remover import Token, TokenRemover +from bot.utils.messages import format_user from tests.helpers import MockBot, MockMessage, autospec @@ -240,7 +241,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(return_value, log_message.format.return_value) log_message.format.assert_called_once_with( - author=f"{self.msg.author.mention} ({self.msg.author})", + author=format_user(self.msg.author), channel=self.msg.channel.mention, user_id=token.user_id, timestamp=token.timestamp, -- cgit v1.2.3 From 3c6cb81e6e36aa3e7321a92ea4e6625f99b3ce7c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 10 Aug 2020 11:42:41 -0700 Subject: Zero-fill discriminators in infraction searches --- bot/cogs/moderation/management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index b4c69acc2..56a601cb7 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -252,7 +252,7 @@ class ModManagement(commands.Cog): else: # Use the user data retrieved from the DB. name = escape_markdown(user['name']) - user_str = f"<@{user['id']}> ({name}#{user['discriminator']})" + user_str = f"<@{user['id']}> ({name}#{user['discriminator']:04})" if active: remaining = time.until_expiration(expires_at) or "Expired" -- cgit v1.2.3 From ddd5536c731c57e7f995727fcb631a83d54d09d2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 11 Aug 2020 15:54:47 -0700 Subject: Replace InfractionSearchQuery with a generic Snowflake converter It's unnecessarily precise to do an fetch user API call in order to distinguish between a user and a reason. Furthermore, a User object isn't actually required for infraction searches - only an ID is. --- bot/cogs/moderation/management.py | 4 ++-- bot/converters.py | 49 ++++++++++++++++++++++++++++++--------- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 56a601cb7..af736d4de 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -10,7 +10,7 @@ from discord.utils import escape_markdown from bot import constants from bot.bot import Bot -from bot.converters import Expiry, InfractionSearchQuery, allowed_strings, proxy_user +from bot.converters import Expiry, Snowflake, allowed_strings, proxy_user from bot.pagination import LinePaginator from bot.utils import messages, time from bot.utils.checks import in_whitelist_check, with_role_check @@ -177,7 +177,7 @@ class ModManagement(commands.Cog): # region: Search infractions @infraction_group.group(name="search", invoke_without_command=True) - async def infraction_search_group(self, ctx: Context, query: InfractionSearchQuery) -> None: + async def infraction_search_group(self, ctx: Context, query: Snowflake) -> None: """Searches for infractions in the database.""" if isinstance(query, discord.User): await ctx.invoke(self.search_user, query) diff --git a/bot/converters.py b/bot/converters.py index 1358cbf1e..4c41d0ece 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -10,6 +10,7 @@ import discord from aiohttp import ClientConnectorError from dateutil.relativedelta import relativedelta from discord.ext.commands import BadArgument, Bot, Context, Converter, IDConverter, UserConverter +from discord.utils import DISCORD_EPOCH, snowflake_time from bot.api import ResponseCodeError from bot.constants import URLs @@ -17,6 +18,8 @@ from bot.utils.regex import INVITE_RE log = logging.getLogger(__name__) +DISCORD_EPOCH_DT = datetime.utcfromtimestamp(DISCORD_EPOCH / 1000) + def allowed_strings(*values, preserve_case: bool = False) -> t.Callable[[str], str]: """ @@ -172,17 +175,42 @@ class ValidURL(Converter): return url -class InfractionSearchQuery(Converter): - """A converter that checks if the argument is a Discord user, and if not, falls back to a string.""" +class Snowflake(IDConverter): + """ + Converts to an int if the argument is a valid Discord snowflake. + + A snowflake is valid if: + + * It consists of 15-21 digits (0-9) + * Its parsed datetime is after the Discord epoch + * Its parsed datetime is less than 1 day after the current time + """ + + async def convert(self, ctx: Context, arg: str) -> int: + """ + Ensure `arg` matches the ID pattern and its timestamp is in range. + + Return `arg` as an int if it's a valid snowflake. + """ + error = f"Invalid snowflake {arg!r}" + + if not self._get_id_match(arg): + raise BadArgument(error) + + snowflake = int(arg) - @staticmethod - async def convert(ctx: Context, arg: str) -> t.Union[discord.Member, str]: - """Check if the argument is a Discord user, and if not, falls back to a string.""" try: - maybe_snowflake = arg.strip("<@!>") - return await ctx.bot.fetch_user(maybe_snowflake) - except (discord.NotFound, discord.HTTPException): - return arg + time = snowflake_time(snowflake) + except (OverflowError, OSError) as e: + # Not sure if this can ever even happen, but let's be safe. + raise BadArgument(f"{error}: {e}") + + if time < DISCORD_EPOCH_DT: + raise BadArgument(f"{error}: timestamp is before the Discord epoch.") + elif (datetime.utcnow() - time).days >= 1: + raise BadArgument(f"{error}: timestamp is too far into the future.") + + return snowflake class Subreddit(Converter): @@ -447,8 +475,7 @@ class UserMentionOrID(UserConverter): """ Converts to a `discord.User`, but only if a mention or userID is provided. - Unlike the default `UserConverter`, it does allow conversion from name, or name#descrim. - + Unlike the default `UserConverter`, it doesn't allow conversion from a name or name#descrim. This is useful in cases where that lookup strategy would lead to ambiguity. """ -- cgit v1.2.3 From b01e854e3870eb90ef2cb9dec70040f4a673387d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 11 Aug 2020 16:37:33 -0700 Subject: Create a UserMention converter --- bot/cogs/moderation/management.py | 6 +++--- bot/converters.py | 18 +++++++++++++++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index af736d4de..c2cca5352 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -10,7 +10,7 @@ from discord.utils import escape_markdown from bot import constants from bot.bot import Bot -from bot.converters import Expiry, Snowflake, allowed_strings, proxy_user +from bot.converters import Expiry, Snowflake, UserMention, allowed_strings, proxy_user from bot.pagination import LinePaginator from bot.utils import messages, time from bot.utils.checks import in_whitelist_check, with_role_check @@ -177,9 +177,9 @@ class ModManagement(commands.Cog): # region: Search infractions @infraction_group.group(name="search", invoke_without_command=True) - async def infraction_search_group(self, ctx: Context, query: Snowflake) -> None: + async def infraction_search_group(self, ctx: Context, query: t.Union[UserMention, Snowflake, str]) -> None: """Searches for infractions in the database.""" - if isinstance(query, discord.User): + if isinstance(query, int): await ctx.invoke(self.search_user, query) else: await ctx.invoke(self.search_reason, query) diff --git a/bot/converters.py b/bot/converters.py index 4c41d0ece..4cfd663ba 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -2,6 +2,7 @@ import logging import re import typing as t from datetime import datetime +from functools import partial from ssl import CertificateError import dateutil.parser @@ -19,6 +20,7 @@ from bot.utils.regex import INVITE_RE log = logging.getLogger(__name__) DISCORD_EPOCH_DT = datetime.utcfromtimestamp(DISCORD_EPOCH / 1000) +RE_USER_MENTION = re.compile(r"<@!?([0-9]+)>$") def allowed_strings(*values, preserve_case: bool = False) -> t.Callable[[str], str]: @@ -481,7 +483,7 @@ class UserMentionOrID(UserConverter): async def convert(self, ctx: Context, argument: str) -> discord.User: """Convert the `arg` to a `discord.User`.""" - match = self._get_id_match(argument) or re.match(r'<@!?([0-9]+)>$', argument) + match = self._get_id_match(argument) or RE_USER_MENTION.match(argument) if match is not None: return await super().convert(ctx, argument) @@ -534,5 +536,19 @@ class FetchedUser(UserConverter): raise BadArgument(f"User `{arg}` does not exist") +def _snowflake_from_regex(pattern: t.Pattern, arg: str) -> int: + """ + Extract the snowflake from `arg` using a regex `pattern` and return it as an int. + + The snowflake is expected to be within the first capture group in `pattern`. + """ + match = pattern.match(arg) + if not match: + raise BadArgument(f"Mention {str!r} is invalid.") + + return int(match.group(1)) + + Expiry = t.Union[Duration, ISODateTime] FetchedMember = t.Union[discord.Member, FetchedUser] +UserMention = partial(_snowflake_from_regex, RE_USER_MENTION) -- cgit v1.2.3 From 257048446a1e37c1bbdad424f8a8465f0491ca83 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 12 Aug 2020 12:11:47 -0700 Subject: Filtering: ignore errors for duplicate offensive messages The error happens when a filter is triggered by a message edit. Fixes #1099 Fixes BOT-6B --- bot/cogs/filtering.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 93cc1c655..99b659bff 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -11,6 +11,7 @@ from discord import Colour, HTTPException, Member, Message, NotFound, TextChanne from discord.ext.commands import Cog from discord.utils import escape_markdown +from bot.api import ResponseCodeError from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.constants import ( @@ -301,9 +302,16 @@ class Filtering(Cog): 'delete_date': delete_date } - await self.bot.api_client.post('bot/offensive-messages', json=data) - self.schedule_msg_delete(data) - log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}") + try: + await self.bot.api_client.post('bot/offensive-messages', json=data) + except ResponseCodeError as e: + if e.status == 400 and "already exists" in e.response_json.get("id", [""])[0]: + log.debug(f"Offensive message {msg.id} already exists.") + else: + log.error(f"Offensive message {msg.id} failed to post: {e}") + else: + self.schedule_msg_delete(data) + log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}") if is_private: channel_str = "via DM" -- cgit v1.2.3 From 601e6824e004ac4886eb6dde5e8d0b933dc389ed Mon Sep 17 00:00:00 2001 From: AtieP <62116490+AtieP@users.noreply.github.com> Date: Thu, 13 Aug 2020 16:22:07 +0200 Subject: Fix typo on the traceback tag See issue #1101 --- bot/resources/tags/traceback.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/traceback.md b/bot/resources/tags/traceback.md index 46ef40aa1..e770fa86d 100644 --- a/bot/resources/tags/traceback.md +++ b/bot/resources/tags/traceback.md @@ -11,7 +11,7 @@ ZeroDivisionError: integer division or modulo by zero ``` The best way to read your traceback is bottom to top. -• Identify the exception raised (e.g. ZeroDivisonError) +• Identify the exception raised (e.g. ZeroDivisionError) • Make note of the line number, and navigate there in your program. • Try to understand why the error occurred. -- cgit v1.2.3 From 1958978e71dc5bd9e4ae007091db72de147afc12 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 13 Aug 2020 18:49:47 +0200 Subject: Verification: add `_send_requests` helper Generic request dispatch method to avoid code duplication with error handling & bad status logging. --- bot/cogs/verification.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index cbf2c51c3..e89f491cf 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -84,6 +84,9 @@ MENTION_UNVERIFIED = discord.AllowedMentions( everyone=False, roles=[discord.Object(constants.Roles.unverified)] ) +# An async function taking a Member param +Request = t.Callable[[discord.Member], t.Awaitable] + def is_verified(member: discord.Member) -> bool: """ @@ -230,6 +233,33 @@ class Verification(Cog): return result + async def _send_requests(self, members: t.Collection[discord.Member], request: Request) -> int: + """ + Pass `members` one by one to `request` handling Discord exceptions. + + This coroutine serves as a generic `request` executor for kicking members and adding + roles, as it allows us to define the error handling logic in one place only. + + Returns the amount of successful requests. Failed requests are logged at info level. + """ + log.info(f"Sending {len(members)} requests") + n_success, bad_statuses = 0, set() + + for member in members: + if is_verified(member): # Member could have verified in the meantime + continue + try: + await request(member) + except discord.HTTPException as http_exc: + bad_statuses.add(http_exc.status) + else: + n_success += 1 + + if bad_statuses: + log.info(f"Failed to send {len(members) - n_success} requests due to following statuses: {bad_statuses}") + + return n_success + async def _kick_members(self, members: t.Collection[discord.Member]) -> int: """ Kick `members` from the PyDis guild. -- cgit v1.2.3 From dc70a018bfbdb5546c05af0a60e0c58dad5e4de1 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 13 Aug 2020 18:51:12 +0200 Subject: Verification: adjust coroutines to use generic dispatch --- bot/cogs/verification.py | 43 +++++++++++-------------------------------- 1 file changed, 11 insertions(+), 32 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index e89f491cf..8f1a773a8 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -264,55 +264,34 @@ class Verification(Cog): """ Kick `members` from the PyDis guild. - Note that this is a potentially destructive operation. Returns the amount of successful - requests. Failed requests are logged at info level. + Note that this is a potentially destructive operation. Returns the amount of successful requests. """ log.info(f"Kicking {len(members)} members from the guild (not verified after {KICKED_AFTER} days)") - n_kicked, bad_statuses = 0, set() - for member in members: - if is_verified(member): # Member could have verified in the meantime - continue + async def kick_request(member: discord.Member) -> None: + """Send `KICKED_MESSAGE` to `member` and kick them from the guild.""" with suppress(discord.Forbidden): - await member.send(KICKED_MESSAGE) # Send message while user is still in guild - try: - await member.kick(reason=f"User has not verified in {KICKED_AFTER} days") - except discord.HTTPException as http_exc: - bad_statuses.add(http_exc.status) - else: - n_kicked += 1 + await member.send(KICKED_MESSAGE) + await member.kick(reason=f"User has not verified in {KICKED_AFTER} days") + n_kicked = await self._send_requests(members, kick_request) self.bot.stats.incr("verification.kicked", count=n_kicked) - if bad_statuses: - log.info(f"Failed to kick {len(members) - n_kicked} members due to following statuses: {bad_statuses}") - return n_kicked async def _give_role(self, members: t.Collection[discord.Member], role: discord.Role) -> int: """ Give `role` to all `members`. - Returns the amount of successful requests. Status codes of unsuccessful requests - are logged at info level. + Returns the amount of successful requests. """ log.info(f"Assigning {role} role to {len(members)} members (not verified after {UNVERIFIED_AFTER} days)") - n_success, bad_statuses = 0, set() - for member in members: - if is_verified(member): # Member could have verified in the meantime - continue - try: - await member.add_roles(role, reason=f"User has not verified in {UNVERIFIED_AFTER} days") - except discord.HTTPException as http_exc: - bad_statuses.add(http_exc.status) - else: - n_success += 1 + async def role_request(member: discord.Member) -> None: + """Add `role` to `member`.""" + await member.add_roles(role, reason=f"User has not verified in {UNVERIFIED_AFTER} days") - if bad_statuses: - log.info(f"Failed to assign {len(members) - n_success} roles due to following statuses: {bad_statuses}") - - return n_success + return await self._send_requests(members, role_request) async def _check_members(self) -> t.Tuple[t.Set[discord.Member], t.Set[discord.Member]]: """ -- cgit v1.2.3 From 0fca2445e2979d6e4bebf6a974c974a5ddd14fbe Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 14 Jun 2020 17:29:43 -0700 Subject: Move extensions into sub-directories --- bot/cogs/alias.py | 2 +- bot/cogs/antimalware.py | 98 ---- bot/cogs/antispam.py | 288 ----------- bot/cogs/backend/__init__.py | 0 bot/cogs/backend/config_verifier.py | 40 ++ bot/cogs/backend/error_handler.py | 287 +++++++++++ bot/cogs/backend/logging.py | 42 ++ bot/cogs/backend/sync/__init__.py | 7 + bot/cogs/backend/sync/cog.py | 180 +++++++ bot/cogs/backend/sync/syncers.py | 347 +++++++++++++ bot/cogs/bot.py | 385 --------------- bot/cogs/clean.py | 272 ---------- bot/cogs/config_verifier.py | 40 -- bot/cogs/defcon.py | 258 ---------- bot/cogs/doc.py | 511 ------------------- bot/cogs/error_handler.py | 287 ----------- bot/cogs/eval.py | 202 -------- bot/cogs/extensions.py | 236 --------- bot/cogs/filter_lists.py | 273 ---------- bot/cogs/filtering.py | 575 ---------------------- bot/cogs/filters/__init__.py | 0 bot/cogs/filters/antimalware.py | 98 ++++ bot/cogs/filters/antispam.py | 288 +++++++++++ bot/cogs/filters/filter_lists.py | 273 ++++++++++ bot/cogs/filters/filtering.py | 575 ++++++++++++++++++++++ bot/cogs/filters/security.py | 31 ++ bot/cogs/filters/token_remover.py | 182 +++++++ bot/cogs/filters/webhook_remover.py | 84 ++++ bot/cogs/help.py | 375 -------------- bot/cogs/info/__init__.py | 0 bot/cogs/info/doc.py | 511 +++++++++++++++++++ bot/cogs/info/help.py | 375 ++++++++++++++ bot/cogs/info/information.py | 422 ++++++++++++++++ bot/cogs/info/python_news.py | 232 +++++++++ bot/cogs/info/reddit.py | 304 ++++++++++++ bot/cogs/info/site.py | 146 ++++++ bot/cogs/info/source.py | 141 ++++++ bot/cogs/info/stats.py | 129 +++++ bot/cogs/info/tags.py | 277 +++++++++++ bot/cogs/info/wolfram.py | 280 +++++++++++ bot/cogs/information.py | 422 ---------------- bot/cogs/jams.py | 150 ------ bot/cogs/logging.py | 42 -- bot/cogs/moderation/__init__.py | 6 +- bot/cogs/moderation/defcon.py | 258 ++++++++++ bot/cogs/moderation/infraction/__init__.py | 0 bot/cogs/moderation/infraction/infractions.py | 370 ++++++++++++++ bot/cogs/moderation/infraction/management.py | 305 ++++++++++++ bot/cogs/moderation/infraction/scheduler.py | 463 +++++++++++++++++ bot/cogs/moderation/infraction/superstarify.py | 239 +++++++++ bot/cogs/moderation/infraction/utils.py | 201 ++++++++ bot/cogs/moderation/infractions.py | 370 -------------- bot/cogs/moderation/management.py | 305 ------------ bot/cogs/moderation/scheduler.py | 463 ----------------- bot/cogs/moderation/superstarify.py | 239 --------- bot/cogs/moderation/utils.py | 201 -------- bot/cogs/moderation/verification.py | 191 +++++++ bot/cogs/moderation/watchchannels/__init__.py | 9 + bot/cogs/moderation/watchchannels/bigbrother.py | 165 +++++++ bot/cogs/moderation/watchchannels/talentpool.py | 264 ++++++++++ bot/cogs/moderation/watchchannels/watchchannel.py | 348 +++++++++++++ bot/cogs/python_news.py | 232 --------- bot/cogs/reddit.py | 304 ------------ bot/cogs/reminders.py | 427 ---------------- bot/cogs/security.py | 31 -- bot/cogs/site.py | 146 ------ bot/cogs/snekbox.py | 349 ------------- bot/cogs/source.py | 141 ------ bot/cogs/stats.py | 129 ----- bot/cogs/sync/__init__.py | 7 - bot/cogs/sync/cog.py | 180 ------- bot/cogs/sync/syncers.py | 347 ------------- bot/cogs/tags.py | 277 ----------- bot/cogs/token_remover.py | 182 ------- bot/cogs/utils.py | 265 ---------- bot/cogs/utils/__init__.py | 0 bot/cogs/utils/bot.py | 385 +++++++++++++++ bot/cogs/utils/clean.py | 272 ++++++++++ bot/cogs/utils/eval.py | 202 ++++++++ bot/cogs/utils/extensions.py | 236 +++++++++ bot/cogs/utils/jams.py | 150 ++++++ bot/cogs/utils/reminders.py | 427 ++++++++++++++++ bot/cogs/utils/snekbox.py | 349 +++++++++++++ bot/cogs/utils/utils.py | 265 ++++++++++ bot/cogs/verification.py | 191 ------- bot/cogs/watchchannels/__init__.py | 9 - bot/cogs/watchchannels/bigbrother.py | 165 ------- bot/cogs/watchchannels/talentpool.py | 264 ---------- bot/cogs/watchchannels/watchchannel.py | 348 ------------- bot/cogs/webhook_remover.py | 84 ---- bot/cogs/wolfram.py | 280 ----------- tests/bot/cogs/moderation/test_infractions.py | 2 +- tests/bot/cogs/sync/test_base.py | 2 +- tests/bot/cogs/sync/test_cog.py | 4 +- tests/bot/cogs/sync/test_roles.py | 2 +- tests/bot/cogs/sync/test_users.py | 2 +- tests/bot/cogs/test_antimalware.py | 2 +- tests/bot/cogs/test_antispam.py | 2 +- tests/bot/cogs/test_information.py | 2 +- tests/bot/cogs/test_security.py | 2 +- tests/bot/cogs/test_snekbox.py | 4 +- tests/bot/cogs/test_token_remover.py | 4 +- 102 files changed, 10368 insertions(+), 10368 deletions(-) delete mode 100644 bot/cogs/antimalware.py delete mode 100644 bot/cogs/antispam.py create mode 100644 bot/cogs/backend/__init__.py create mode 100644 bot/cogs/backend/config_verifier.py create mode 100644 bot/cogs/backend/error_handler.py create mode 100644 bot/cogs/backend/logging.py create mode 100644 bot/cogs/backend/sync/__init__.py create mode 100644 bot/cogs/backend/sync/cog.py create mode 100644 bot/cogs/backend/sync/syncers.py delete mode 100644 bot/cogs/bot.py delete mode 100644 bot/cogs/clean.py delete mode 100644 bot/cogs/config_verifier.py delete mode 100644 bot/cogs/defcon.py delete mode 100644 bot/cogs/doc.py delete mode 100644 bot/cogs/error_handler.py delete mode 100644 bot/cogs/eval.py delete mode 100644 bot/cogs/extensions.py delete mode 100644 bot/cogs/filter_lists.py delete mode 100644 bot/cogs/filtering.py create mode 100644 bot/cogs/filters/__init__.py create mode 100644 bot/cogs/filters/antimalware.py create mode 100644 bot/cogs/filters/antispam.py create mode 100644 bot/cogs/filters/filter_lists.py create mode 100644 bot/cogs/filters/filtering.py create mode 100644 bot/cogs/filters/security.py create mode 100644 bot/cogs/filters/token_remover.py create mode 100644 bot/cogs/filters/webhook_remover.py delete mode 100644 bot/cogs/help.py create mode 100644 bot/cogs/info/__init__.py create mode 100644 bot/cogs/info/doc.py create mode 100644 bot/cogs/info/help.py create mode 100644 bot/cogs/info/information.py create mode 100644 bot/cogs/info/python_news.py create mode 100644 bot/cogs/info/reddit.py create mode 100644 bot/cogs/info/site.py create mode 100644 bot/cogs/info/source.py create mode 100644 bot/cogs/info/stats.py create mode 100644 bot/cogs/info/tags.py create mode 100644 bot/cogs/info/wolfram.py delete mode 100644 bot/cogs/information.py delete mode 100644 bot/cogs/jams.py delete mode 100644 bot/cogs/logging.py create mode 100644 bot/cogs/moderation/defcon.py create mode 100644 bot/cogs/moderation/infraction/__init__.py create mode 100644 bot/cogs/moderation/infraction/infractions.py create mode 100644 bot/cogs/moderation/infraction/management.py create mode 100644 bot/cogs/moderation/infraction/scheduler.py create mode 100644 bot/cogs/moderation/infraction/superstarify.py create mode 100644 bot/cogs/moderation/infraction/utils.py delete mode 100644 bot/cogs/moderation/infractions.py delete mode 100644 bot/cogs/moderation/management.py delete mode 100644 bot/cogs/moderation/scheduler.py delete mode 100644 bot/cogs/moderation/superstarify.py delete mode 100644 bot/cogs/moderation/utils.py create mode 100644 bot/cogs/moderation/verification.py create mode 100644 bot/cogs/moderation/watchchannels/__init__.py create mode 100644 bot/cogs/moderation/watchchannels/bigbrother.py create mode 100644 bot/cogs/moderation/watchchannels/talentpool.py create mode 100644 bot/cogs/moderation/watchchannels/watchchannel.py delete mode 100644 bot/cogs/python_news.py delete mode 100644 bot/cogs/reddit.py delete mode 100644 bot/cogs/reminders.py delete mode 100644 bot/cogs/security.py delete mode 100644 bot/cogs/site.py delete mode 100644 bot/cogs/snekbox.py delete mode 100644 bot/cogs/source.py delete mode 100644 bot/cogs/stats.py delete mode 100644 bot/cogs/sync/__init__.py delete mode 100644 bot/cogs/sync/cog.py delete mode 100644 bot/cogs/sync/syncers.py delete mode 100644 bot/cogs/tags.py delete mode 100644 bot/cogs/token_remover.py delete mode 100644 bot/cogs/utils.py create mode 100644 bot/cogs/utils/__init__.py create mode 100644 bot/cogs/utils/bot.py create mode 100644 bot/cogs/utils/clean.py create mode 100644 bot/cogs/utils/eval.py create mode 100644 bot/cogs/utils/extensions.py create mode 100644 bot/cogs/utils/jams.py create mode 100644 bot/cogs/utils/reminders.py create mode 100644 bot/cogs/utils/snekbox.py create mode 100644 bot/cogs/utils/utils.py delete mode 100644 bot/cogs/verification.py delete mode 100644 bot/cogs/watchchannels/__init__.py delete mode 100644 bot/cogs/watchchannels/bigbrother.py delete mode 100644 bot/cogs/watchchannels/talentpool.py delete mode 100644 bot/cogs/watchchannels/watchchannel.py delete mode 100644 bot/cogs/webhook_remover.py delete mode 100644 bot/cogs/wolfram.py diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 55c7efe65..3c5a35c24 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -8,7 +8,7 @@ from discord.ext.commands import ( ) from bot.bot import Bot -from bot.cogs.extensions import Extension +from bot.cogs.utils.extensions import Extension from bot.converters import FetchedMember, TagNameConverter from bot.pagination import LinePaginator diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py deleted file mode 100644 index c76bd2c60..000000000 --- a/bot/cogs/antimalware.py +++ /dev/null @@ -1,98 +0,0 @@ -import logging -import typing as t -from os.path import splitext - -from discord import Embed, Message, NotFound -from discord.ext.commands import Cog - -from bot.bot import Bot -from bot.constants import Channels, STAFF_ROLES, URLs - -log = logging.getLogger(__name__) - -PY_EMBED_DESCRIPTION = ( - "It looks like you tried to attach a Python file - " - f"please use a code-pasting service such as {URLs.site_schema}{URLs.site_paste}" -) - -TXT_EMBED_DESCRIPTION = ( - "**Uh-oh!** It looks like your message got zapped by our spam filter. " - "We currently don't allow `.txt` attachments, so here are some tips to help you travel safely: \n\n" - "• If you attempted to send a message longer than 2000 characters, try shortening your message " - "to fit within the character limit or use a pasting service (see below) \n\n" - "• If you tried to show someone your code, you can use codeblocks \n(run `!code-blocks` in " - "{cmd_channel_mention} for more information) or use a pasting service like: " - f"\n\n{URLs.site_schema}{URLs.site_paste}" -) - -DISALLOWED_EMBED_DESCRIPTION = ( - "It looks like you tried to attach file type(s) that we do not allow ({blocked_extensions_str}). " - "We currently allow the following file types: **{joined_whitelist}**.\n\n" - "Feel free to ask in {meta_channel_mention} if you think this is a mistake." -) - - -class AntiMalware(Cog): - """Delete messages which contain attachments with non-whitelisted file extensions.""" - - def __init__(self, bot: Bot): - self.bot = bot - - def _get_whitelisted_file_formats(self) -> list: - """Get the file formats currently on the whitelist.""" - return self.bot.filter_list_cache['FILE_FORMAT.True'].keys() - - def _get_disallowed_extensions(self, message: Message) -> t.Iterable[str]: - """Get an iterable containing all the disallowed extensions of attachments.""" - file_extensions = {splitext(attachment.filename.lower())[1] for attachment in message.attachments} - extensions_blocked = file_extensions - set(self._get_whitelisted_file_formats()) - return extensions_blocked - - @Cog.listener() - async def on_message(self, message: Message) -> None: - """Identify messages with prohibited attachments.""" - # Return when message don't have attachment and don't moderate DMs - if not message.attachments or not message.guild: - return - - # Check if user is staff, if is, return - # Since we only care that roles exist to iterate over, check for the attr rather than a User/Member instance - if hasattr(message.author, "roles") and any(role.id in STAFF_ROLES for role in message.author.roles): - return - - embed = Embed() - extensions_blocked = self._get_disallowed_extensions(message) - blocked_extensions_str = ', '.join(extensions_blocked) - if ".py" in extensions_blocked: - # Short-circuit on *.py files to provide a pastebin link - embed.description = PY_EMBED_DESCRIPTION - elif ".txt" in extensions_blocked: - # Work around Discord AutoConversion of messages longer than 2000 chars to .txt - cmd_channel = self.bot.get_channel(Channels.bot_commands) - embed.description = TXT_EMBED_DESCRIPTION.format(cmd_channel_mention=cmd_channel.mention) - elif extensions_blocked: - meta_channel = self.bot.get_channel(Channels.meta) - embed.description = DISALLOWED_EMBED_DESCRIPTION.format( - joined_whitelist=', '.join(self._get_whitelisted_file_formats()), - blocked_extensions_str=blocked_extensions_str, - meta_channel_mention=meta_channel.mention, - ) - - if embed.description: - log.info( - f"User '{message.author}' ({message.author.id}) uploaded blacklisted file(s): {blocked_extensions_str}", - extra={"attachment_list": [attachment.filename for attachment in message.attachments]} - ) - - await message.channel.send(f"Hey {message.author.mention}!", embed=embed) - - # Delete the offending message: - try: - await message.delete() - except NotFound: - log.info(f"Tried to delete message `{message.id}`, but message could not be found.") - - -def setup(bot: Bot) -> None: - """Load the AntiMalware cog.""" - bot.add_cog(AntiMalware(bot)) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py deleted file mode 100644 index 0bcca578d..000000000 --- a/bot/cogs/antispam.py +++ /dev/null @@ -1,288 +0,0 @@ -import asyncio -import logging -from collections.abc import Mapping -from dataclasses import dataclass, field -from datetime import datetime, timedelta -from operator import itemgetter -from typing import Dict, Iterable, List, Set - -from discord import Colour, Member, Message, NotFound, Object, TextChannel -from discord.ext.commands import Cog - -from bot import rules -from bot.bot import Bot -from bot.cogs.moderation import ModLog -from bot.constants import ( - AntiSpam as AntiSpamConfig, Channels, - Colours, DEBUG_MODE, Event, Filter, - Guild as GuildConfig, Icons, - STAFF_ROLES, -) -from bot.converters import Duration -from bot.utils.messages import send_attachments - - -log = logging.getLogger(__name__) - -RULE_FUNCTION_MAPPING = { - 'attachments': rules.apply_attachments, - 'burst': rules.apply_burst, - 'burst_shared': rules.apply_burst_shared, - 'chars': rules.apply_chars, - 'discord_emojis': rules.apply_discord_emojis, - 'duplicates': rules.apply_duplicates, - 'links': rules.apply_links, - 'mentions': rules.apply_mentions, - 'newlines': rules.apply_newlines, - 'role_mentions': rules.apply_role_mentions -} - - -@dataclass -class DeletionContext: - """Represents a Deletion Context for a single spam event.""" - - channel: TextChannel - members: Dict[int, Member] = field(default_factory=dict) - rules: Set[str] = field(default_factory=set) - messages: Dict[int, Message] = field(default_factory=dict) - attachments: List[List[str]] = field(default_factory=list) - - async def add(self, rule_name: str, members: Iterable[Member], messages: Iterable[Message]) -> None: - """Adds new rule violation events to the deletion context.""" - self.rules.add(rule_name) - - for member in members: - if member.id not in self.members: - self.members[member.id] = member - - for message in messages: - if message.id not in self.messages: - self.messages[message.id] = message - - # Re-upload attachments - destination = message.guild.get_channel(Channels.attachment_log) - urls = await send_attachments(message, destination, link_large=False) - self.attachments.append(urls) - - async def upload_messages(self, actor_id: int, modlog: ModLog) -> None: - """Method that takes care of uploading the queue and posting modlog alert.""" - triggered_by_users = ", ".join(f"{m} (`{m.id}`)" for m in self.members.values()) - - mod_alert_message = ( - f"**Triggered by:** {triggered_by_users}\n" - f"**Channel:** {self.channel.mention}\n" - f"**Rules:** {', '.join(rule for rule in self.rules)}\n" - ) - - # For multiple messages or those with excessive newlines, use the logs API - if len(self.messages) > 1 or 'newlines' in self.rules: - url = await modlog.upload_log(self.messages.values(), actor_id, self.attachments) - mod_alert_message += f"A complete log of the offending messages can be found [here]({url})" - else: - mod_alert_message += "Message:\n" - [message] = self.messages.values() - content = message.clean_content - remaining_chars = 2040 - len(mod_alert_message) - - if len(content) > remaining_chars: - content = content[:remaining_chars] + "..." - - mod_alert_message += f"{content}" - - *_, last_message = self.messages.values() - await modlog.send_log_message( - icon_url=Icons.filtering, - colour=Colour(Colours.soft_red), - title="Spam detected!", - text=mod_alert_message, - thumbnail=last_message.author.avatar_url_as(static_format="png"), - channel_id=Channels.mod_alerts, - ping_everyone=AntiSpamConfig.ping_everyone - ) - - -class AntiSpam(Cog): - """Cog that controls our anti-spam measures.""" - - def __init__(self, bot: Bot, validation_errors: Dict[str, str]) -> None: - self.bot = bot - self.validation_errors = validation_errors - role_id = AntiSpamConfig.punishment['role_id'] - self.muted_role = Object(role_id) - self.expiration_date_converter = Duration() - - self.message_deletion_queue = dict() - - self.bot.loop.create_task(self.alert_on_validation_error()) - - @property - def mod_log(self) -> ModLog: - """Allows for easy access of the ModLog cog.""" - return self.bot.get_cog("ModLog") - - async def alert_on_validation_error(self) -> None: - """Unloads the cog and alerts admins if configuration validation failed.""" - await self.bot.wait_until_guild_available() - if self.validation_errors: - body = "**The following errors were encountered:**\n" - body += "\n".join(f"- {error}" for error in self.validation_errors.values()) - body += "\n\n**The cog has been unloaded.**" - - await self.mod_log.send_log_message( - title="Error: AntiSpam configuration validation failed!", - text=body, - ping_everyone=True, - icon_url=Icons.token_removed, - colour=Colour.red() - ) - - self.bot.remove_cog(self.__class__.__name__) - return - - @Cog.listener() - async def on_message(self, message: Message) -> None: - """Applies the antispam rules to each received message.""" - if ( - not message.guild - or message.guild.id != GuildConfig.id - or message.author.bot - or (message.channel.id in Filter.channel_whitelist and not DEBUG_MODE) - or (any(role.id in STAFF_ROLES for role in message.author.roles) and not DEBUG_MODE) - ): - return - - # Fetch the rule configuration with the highest rule interval. - max_interval_config = max( - AntiSpamConfig.rules.values(), - key=itemgetter('interval') - ) - max_interval = max_interval_config['interval'] - - # Store history messages since `interval` seconds ago in a list to prevent unnecessary API calls. - earliest_relevant_at = datetime.utcnow() - timedelta(seconds=max_interval) - relevant_messages = [ - msg async for msg in message.channel.history(after=earliest_relevant_at, oldest_first=False) - if not msg.author.bot - ] - - for rule_name in AntiSpamConfig.rules: - rule_config = AntiSpamConfig.rules[rule_name] - rule_function = RULE_FUNCTION_MAPPING[rule_name] - - # Create a list of messages that were sent in the interval that the rule cares about. - latest_interesting_stamp = datetime.utcnow() - timedelta(seconds=rule_config['interval']) - messages_for_rule = [ - msg for msg in relevant_messages if msg.created_at > latest_interesting_stamp - ] - result = await rule_function(message, messages_for_rule, rule_config) - - # If the rule returns `None`, that means the message didn't violate it. - # If it doesn't, it returns a tuple in the form `(str, Iterable[discord.Member])` - # which contains the reason for why the message violated the rule and - # an iterable of all members that violated the rule. - if result is not None: - self.bot.stats.incr(f"mod_alerts.{rule_name}") - reason, members, relevant_messages = result - full_reason = f"`{rule_name}` rule: {reason}" - - # If there's no spam event going on for this channel, start a new Message Deletion Context - channel = message.channel - if channel.id not in self.message_deletion_queue: - log.trace(f"Creating queue for channel `{channel.id}`") - self.message_deletion_queue[message.channel.id] = DeletionContext(channel) - self.bot.loop.create_task(self._process_deletion_context(message.channel.id)) - - # Add the relevant of this trigger to the Deletion Context - await self.message_deletion_queue[message.channel.id].add( - rule_name=rule_name, - members=members, - messages=relevant_messages - ) - - for member in members: - - # Fire it off as a background task to ensure - # that the sleep doesn't block further tasks - self.bot.loop.create_task( - self.punish(message, member, full_reason) - ) - - await self.maybe_delete_messages(channel, relevant_messages) - break - - async def punish(self, msg: Message, member: Member, reason: str) -> None: - """Punishes the given member for triggering an antispam rule.""" - if not any(role.id == self.muted_role.id for role in member.roles): - remove_role_after = AntiSpamConfig.punishment['remove_after'] - - # Get context and make sure the bot becomes the actor of infraction by patching the `author` attributes - context = await self.bot.get_context(msg) - context.author = self.bot.user - context.message.author = self.bot.user - - # Since we're going to invoke the tempmute command directly, we need to manually call the converter. - dt_remove_role_after = await self.expiration_date_converter.convert(context, f"{remove_role_after}S") - await context.invoke( - self.bot.get_command('tempmute'), - member, - dt_remove_role_after, - reason=reason - ) - - async def maybe_delete_messages(self, channel: TextChannel, messages: List[Message]) -> None: - """Cleans the messages if cleaning is configured.""" - if AntiSpamConfig.clean_offending: - # If we have more than one message, we can use bulk delete. - if len(messages) > 1: - message_ids = [message.id for message in messages] - self.mod_log.ignore(Event.message_delete, *message_ids) - await channel.delete_messages(messages) - - # Otherwise, the bulk delete endpoint will throw up. - # Delete the message directly instead. - else: - self.mod_log.ignore(Event.message_delete, messages[0].id) - try: - await messages[0].delete() - except NotFound: - log.info(f"Tried to delete message `{messages[0].id}`, but message could not be found.") - - async def _process_deletion_context(self, context_id: int) -> None: - """Processes the Deletion Context queue.""" - log.trace("Sleeping before processing message deletion queue.") - await asyncio.sleep(10) - - if context_id not in self.message_deletion_queue: - log.error(f"Started processing deletion queue for context `{context_id}`, but it was not found!") - return - - deletion_context = self.message_deletion_queue.pop(context_id) - await deletion_context.upload_messages(self.bot.user.id, self.mod_log) - - -def validate_config(rules_: Mapping = AntiSpamConfig.rules) -> Dict[str, str]: - """Validates the antispam configs.""" - validation_errors = {} - for name, config in rules_.items(): - if name not in RULE_FUNCTION_MAPPING: - log.error( - f"Unrecognized antispam rule `{name}`. " - f"Valid rules are: {', '.join(RULE_FUNCTION_MAPPING)}" - ) - validation_errors[name] = f"`{name}` is not recognized as an antispam rule." - continue - for required_key in ('interval', 'max'): - if required_key not in config: - log.error( - f"`{required_key}` is required but was not " - f"set in rule `{name}`'s configuration." - ) - validation_errors[name] = f"Key `{required_key}` is required but not set for rule `{name}`" - return validation_errors - - -def setup(bot: Bot) -> None: - """Validate the AntiSpam configs and load the AntiSpam cog.""" - validation_errors = validate_config() - bot.add_cog(AntiSpam(bot, validation_errors)) diff --git a/bot/cogs/backend/__init__.py b/bot/cogs/backend/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bot/cogs/backend/config_verifier.py b/bot/cogs/backend/config_verifier.py new file mode 100644 index 000000000..d72c6c22e --- /dev/null +++ b/bot/cogs/backend/config_verifier.py @@ -0,0 +1,40 @@ +import logging + +from discord.ext.commands import Cog + +from bot import constants +from bot.bot import Bot + + +log = logging.getLogger(__name__) + + +class ConfigVerifier(Cog): + """Verify config on startup.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.channel_verify_task = self.bot.loop.create_task(self.verify_channels()) + + async def verify_channels(self) -> None: + """ + Verify channels. + + If any channels in config aren't present in server, log them in a warning. + """ + await self.bot.wait_until_guild_available() + server = self.bot.get_guild(constants.Guild.id) + + server_channel_ids = {channel.id for channel in server.channels} + invalid_channels = [ + channel_name for channel_name, channel_id in constants.Channels + if channel_id not in server_channel_ids + ] + + if invalid_channels: + log.warning(f"Configured channels do not exist in server: {', '.join(invalid_channels)}.") + + +def setup(bot: Bot) -> None: + """Load the ConfigVerifier cog.""" + bot.add_cog(ConfigVerifier(bot)) diff --git a/bot/cogs/backend/error_handler.py b/bot/cogs/backend/error_handler.py new file mode 100644 index 000000000..f9d4de638 --- /dev/null +++ b/bot/cogs/backend/error_handler.py @@ -0,0 +1,287 @@ +import contextlib +import logging +import typing as t + +from discord import Embed +from discord.ext.commands import Cog, Context, errors +from sentry_sdk import push_scope + +from bot.api import ResponseCodeError +from bot.bot import Bot +from bot.constants import Channels, Colours +from bot.converters import TagNameConverter +from bot.utils.checks import InWhitelistCheckFailure + +log = logging.getLogger(__name__) + + +class ErrorHandler(Cog): + """Handles errors emitted from commands.""" + + def __init__(self, bot: Bot): + self.bot = bot + + def _get_error_embed(self, title: str, body: str) -> Embed: + """Return an embed that contains the exception.""" + return Embed( + title=title, + colour=Colours.soft_red, + description=body + ) + + @Cog.listener() + async def on_command_error(self, ctx: Context, e: errors.CommandError) -> None: + """ + Provide generic command error handling. + + Error handling is deferred to any local error handler, if present. This is done by + checking for the presence of a `handled` attribute on the error. + + Error handling emits a single error message in the invoking context `ctx` and a log message, + prioritised as follows: + + 1. If the name fails to match a command: + * If it matches shh+ or unshh+, the channel is silenced or unsilenced respectively. + Otherwise if it matches a tag, the tag is invoked + * If CommandNotFound is raised when invoking the tag (determined by the presence of the + `invoked_from_error_handler` attribute), this error is treated as being unexpected + and therefore sends an error message + * Commands in the verification channel are ignored + 2. UserInputError: see `handle_user_input_error` + 3. CheckFailure: see `handle_check_failure` + 4. CommandOnCooldown: send an error message in the invoking context + 5. ResponseCodeError: see `handle_api_error` + 6. Otherwise, if not a DisabledCommand, handling is deferred to `handle_unexpected_error` + """ + command = ctx.command + + if hasattr(e, "handled"): + 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 await self.try_silence(ctx): + return + if ctx.channel.id != Channels.verification: + # Try to look for a tag with the command's name + await self.try_get_tag(ctx) + return # Exit early to avoid logging. + elif isinstance(e, errors.UserInputError): + await self.handle_user_input_error(ctx, e) + elif isinstance(e, errors.CheckFailure): + await self.handle_check_failure(ctx, e) + elif isinstance(e, errors.CommandOnCooldown): + await ctx.send(e) + elif isinstance(e, errors.CommandInvokeError): + if isinstance(e.original, ResponseCodeError): + await self.handle_api_error(ctx, e.original) + else: + await self.handle_unexpected_error(ctx, e.original) + return # Exit early to avoid logging. + elif not isinstance(e, errors.DisabledCommand): + # ConversionError, MaxConcurrencyReached, ExtensionError + await self.handle_unexpected_error(ctx, e) + return # Exit early to avoid logging. + + log.debug( + f"Command {command} invoked by {ctx.message.author} with error " + f"{e.__class__.__name__}: {e}" + ) + + @staticmethod + def get_help_command(ctx: Context) -> t.Coroutine: + """Return a prepared `help` command invocation coroutine.""" + if ctx.command: + return ctx.send_help(ctx.command) + + return ctx.send_help() + + async def try_silence(self, ctx: Context) -> bool: + """ + Attempt to invoke the silence or unsilence command if invoke with matches a pattern. + + Respecting the checks if: + * invoked with `shh+` silence channel for amount of h's*2 with max of 15. + * invoked with `unshh+` unsilence channel + Return bool depending on success of command. + """ + command = ctx.invoked_with.lower() + silence_command = self.bot.get_command("silence") + ctx.invoked_from_error_handler = True + try: + if not await silence_command.can_run(ctx): + log.debug("Cancelling attempt to invoke silence/unsilence due to failed checks.") + return False + except errors.CommandError: + log.debug("Cancelling attempt to invoke silence/unsilence due to failed checks.") + return False + if command.startswith("shh"): + await ctx.invoke(silence_command, duration=min(command.count("h")*2, 15)) + return True + elif command.startswith("unshh"): + await ctx.invoke(self.bot.get_command("unsilence")) + return True + return False + + async def try_get_tag(self, ctx: Context) -> None: + """ + Attempt to display a tag by interpreting the command name as a tag name. + + The invocation of tags get respects its checks. Any CommandErrors raised will be handled + by `on_command_error`, but the `invoked_from_error_handler` attribute will be added to + the context to prevent infinite recursion in the case of a CommandNotFound exception. + """ + tags_get_command = self.bot.get_command("tags get") + ctx.invoked_from_error_handler = True + + log_msg = "Cancelling attempt to fall back to a tag due to failed checks." + try: + if not await tags_get_command.can_run(ctx): + log.debug(log_msg) + return + except errors.CommandError as tag_error: + log.debug(log_msg) + await self.on_command_error(ctx, tag_error) + return + + try: + tag_name = await TagNameConverter.convert(ctx, ctx.invoked_with) + except errors.BadArgument: + log.debug( + f"{ctx.author} tried to use an invalid command " + f"and the fallback tag failed validation in TagNameConverter." + ) + else: + with contextlib.suppress(ResponseCodeError): + await ctx.invoke(tags_get_command, tag_name=tag_name) + # Return to not raise the exception + return + + async def handle_user_input_error(self, ctx: Context, e: errors.UserInputError) -> None: + """ + Send an error message in `ctx` for UserInputError, sometimes invoking the help command too. + + * MissingRequiredArgument: send an error message with arg name and the help command + * TooManyArguments: send an error message and the help command + * BadArgument: send an error message and the help command + * BadUnionArgument: send an error message including the error produced by the last converter + * 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): + embed = self._get_error_embed("Missing required argument", e.param.name) + await ctx.send(embed=embed) + await prepared_help_command + self.bot.stats.incr("errors.missing_required_argument") + elif isinstance(e, errors.TooManyArguments): + embed = self._get_error_embed("Too many arguments", str(e)) + await ctx.send(embed=embed) + await prepared_help_command + self.bot.stats.incr("errors.too_many_arguments") + elif isinstance(e, errors.BadArgument): + embed = self._get_error_embed("Bad argument", str(e)) + await ctx.send(embed=embed) + await prepared_help_command + self.bot.stats.incr("errors.bad_argument") + elif isinstance(e, errors.BadUnionArgument): + embed = self._get_error_embed("Bad argument", f"{e}\n{e.errors[-1]}") + await ctx.send(embed=embed) + 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) + self.bot.stats.incr("errors.argument_parsing_error") + else: + embed = self._get_error_embed( + "Input error", + "Something about your input seems off. Check the arguments and try again." + ) + await ctx.send(embed=embed) + await prepared_help_command + self.bot.stats.incr("errors.other_user_input_error") + + @staticmethod + async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None: + """ + Send an error message in `ctx` for certain types of CheckFailure. + + The following types are handled: + + * BotMissingPermissions + * BotMissingRole + * BotMissingAnyRole + * NoPrivateMessage + * InWhitelistCheckFailure + """ + bot_missing_errors = ( + errors.BotMissingPermissions, + errors.BotMissingRole, + errors.BotMissingAnyRole + ) + + if isinstance(e, bot_missing_errors): + ctx.bot.stats.incr("errors.bot_permission_error") + await ctx.send( + "Sorry, it looks like I don't have the permissions or roles I need to do that." + ) + elif isinstance(e, (InWhitelistCheckFailure, errors.NoPrivateMessage)): + ctx.bot.stats.incr("errors.wrong_channel_or_dm_error") + await ctx.send(e) + + @staticmethod + async def handle_api_error(ctx: Context, e: ResponseCodeError) -> None: + """Send an error message in `ctx` for ResponseCodeError and log it.""" + if e.status == 404: + await ctx.send("There does not seem to be anything matching your query.") + log.debug(f"API responded with 404 for command {ctx.command}") + ctx.bot.stats.incr("errors.api_error_404") + elif e.status == 400: + content = await e.response.json() + log.debug(f"API responded with 400 for command {ctx.command}: %r.", content) + await ctx.send("According to the API, your request is malformed.") + ctx.bot.stats.incr("errors.api_error_400") + elif 500 <= e.status < 600: + await ctx.send("Sorry, there seems to be an internal issue with the API.") + log.warning(f"API responded with {e.status} for command {ctx.command}") + ctx.bot.stats.incr("errors.api_internal_server_error") + else: + await ctx.send(f"Got an unexpected status code from the API (`{e.status}`).") + log.warning(f"Unexpected API response for command {ctx.command}: {e.status}") + ctx.bot.stats.incr(f"errors.api_error_{e.status}") + + @staticmethod + async def handle_unexpected_error(ctx: Context, e: errors.CommandError) -> None: + """Send a generic error message in `ctx` and log the exception as an error with exc_info.""" + await ctx.send( + f"Sorry, an unexpected error occurred. Please let us know!\n\n" + f"```{e.__class__.__name__}: {e}```" + ) + + ctx.bot.stats.incr("errors.unexpected") + + with push_scope() as scope: + scope.user = { + "id": ctx.author.id, + "username": str(ctx.author) + } + + scope.set_tag("command", ctx.command.qualified_name) + scope.set_tag("message_id", ctx.message.id) + scope.set_tag("channel_id", ctx.channel.id) + + scope.set_extra("full_message", ctx.message.content) + + if ctx.guild is not None: + scope.set_extra( + "jump_to", + f"https://discordapp.com/channels/{ctx.guild.id}/{ctx.channel.id}/{ctx.message.id}" + ) + + log.error(f"Error executing command invoked by {ctx.message.author}: {ctx.message.content}", exc_info=e) + + +def setup(bot: Bot) -> None: + """Load the ErrorHandler cog.""" + bot.add_cog(ErrorHandler(bot)) diff --git a/bot/cogs/backend/logging.py b/bot/cogs/backend/logging.py new file mode 100644 index 000000000..94fa2b139 --- /dev/null +++ b/bot/cogs/backend/logging.py @@ -0,0 +1,42 @@ +import logging + +from discord import Embed +from discord.ext.commands import Cog + +from bot.bot import Bot +from bot.constants import Channels, DEBUG_MODE + + +log = logging.getLogger(__name__) + + +class Logging(Cog): + """Debug logging module.""" + + def __init__(self, bot: Bot): + self.bot = bot + + self.bot.loop.create_task(self.startup_greeting()) + + async def startup_greeting(self) -> None: + """Announce our presence to the configured devlog channel.""" + await self.bot.wait_until_guild_available() + log.info("Bot connected!") + + embed = Embed(description="Connected!") + embed.set_author( + name="Python Bot", + url="https://github.com/python-discord/bot", + icon_url=( + "https://raw.githubusercontent.com/" + "python-discord/branding/master/logos/logo_circle/logo_circle_large.png" + ) + ) + + if not DEBUG_MODE: + await self.bot.get_channel(Channels.dev_log).send(embed=embed) + + +def setup(bot: Bot) -> None: + """Load the Logging cog.""" + bot.add_cog(Logging(bot)) diff --git a/bot/cogs/backend/sync/__init__.py b/bot/cogs/backend/sync/__init__.py new file mode 100644 index 000000000..fe7df4e9b --- /dev/null +++ b/bot/cogs/backend/sync/__init__.py @@ -0,0 +1,7 @@ +from bot.bot import Bot +from .cog import Sync + + +def setup(bot: Bot) -> None: + """Load the Sync cog.""" + bot.add_cog(Sync(bot)) diff --git a/bot/cogs/backend/sync/cog.py b/bot/cogs/backend/sync/cog.py new file mode 100644 index 000000000..274845a50 --- /dev/null +++ b/bot/cogs/backend/sync/cog.py @@ -0,0 +1,180 @@ +import logging +from typing import Any, Dict + +from discord import Member, Role, User +from discord.ext import commands +from discord.ext.commands import Cog, Context + +from bot import constants +from bot.api import ResponseCodeError +from bot.bot import Bot +from . import syncers + +log = logging.getLogger(__name__) + + +class Sync(Cog): + """Captures relevant events and sends them to the site.""" + + def __init__(self, bot: Bot) -> None: + self.bot = bot + self.role_syncer = syncers.RoleSyncer(self.bot) + self.user_syncer = syncers.UserSyncer(self.bot) + + self.bot.loop.create_task(self.sync_guild()) + + async def sync_guild(self) -> None: + """Syncs the roles/users of the guild with the database.""" + await self.bot.wait_until_guild_available() + + guild = self.bot.get_guild(constants.Guild.id) + if guild is None: + return + + for syncer in (self.role_syncer, self.user_syncer): + await syncer.sync(guild) + + async def patch_user(self, user_id: int, json: Dict[str, Any], ignore_404: bool = False) -> None: + """Send a PATCH request to partially update a user in the database.""" + try: + await self.bot.api_client.patch(f"bot/users/{user_id}", json=json) + except ResponseCodeError as e: + if e.response.status != 404: + raise + if not ignore_404: + log.warning("Unable to update user, got 404. Assuming race condition from join event.") + + @Cog.listener() + async def on_guild_role_create(self, role: Role) -> None: + """Adds newly create role to the database table over the API.""" + if role.guild.id != constants.Guild.id: + return + + await self.bot.api_client.post( + 'bot/roles', + json={ + 'colour': role.colour.value, + 'id': role.id, + 'name': role.name, + 'permissions': role.permissions.value, + 'position': role.position, + } + ) + + @Cog.listener() + async def on_guild_role_delete(self, role: Role) -> None: + """Deletes role from the database when it's deleted from the guild.""" + if role.guild.id != constants.Guild.id: + return + + await self.bot.api_client.delete(f'bot/roles/{role.id}') + + @Cog.listener() + async def on_guild_role_update(self, before: Role, after: Role) -> None: + """Syncs role with the database if any of the stored attributes were updated.""" + if after.guild.id != constants.Guild.id: + return + + was_updated = ( + before.name != after.name + or before.colour != after.colour + or before.permissions != after.permissions + or before.position != after.position + ) + + if was_updated: + await self.bot.api_client.put( + f'bot/roles/{after.id}', + json={ + 'colour': after.colour.value, + 'id': after.id, + 'name': after.name, + 'permissions': after.permissions.value, + 'position': after.position, + } + ) + + @Cog.listener() + async def on_member_join(self, member: Member) -> None: + """ + Adds a new user or updates existing user to the database when a member joins the guild. + + If the joining member is a user that is already known to the database (i.e., a user that + previously left), it will update the user's information. If the user is not yet known by + the database, the user is added. + """ + if member.guild.id != constants.Guild.id: + return + + packed = { + 'discriminator': int(member.discriminator), + 'id': member.id, + 'in_guild': True, + 'name': member.name, + 'roles': sorted(role.id for role in member.roles) + } + + got_error = False + + try: + # First try an update of the user to set the `in_guild` field and other + # fields that may have changed since the last time we've seen them. + await self.bot.api_client.put(f'bot/users/{member.id}', json=packed) + + except ResponseCodeError as e: + # If we didn't get 404, something else broke - propagate it up. + if e.response.status != 404: + raise + + got_error = True # yikes + + if got_error: + # If we got `404`, the user is new. Create them. + await self.bot.api_client.post('bot/users', json=packed) + + @Cog.listener() + async def on_member_remove(self, member: Member) -> None: + """Set the in_guild field to False when a member leaves the guild.""" + if member.guild.id != constants.Guild.id: + return + + await self.patch_user(member.id, json={"in_guild": False}) + + @Cog.listener() + async def on_member_update(self, before: Member, after: Member) -> None: + """Update the roles of the member in the database if a change is detected.""" + if after.guild.id != constants.Guild.id: + return + + if before.roles != after.roles: + updated_information = {"roles": sorted(role.id for role in after.roles)} + await self.patch_user(after.id, json=updated_information) + + @Cog.listener() + async def on_user_update(self, before: User, after: User) -> None: + """Update the user information in the database if a relevant change is detected.""" + attrs = ("name", "discriminator") + if any(getattr(before, attr) != getattr(after, attr) for attr in attrs): + updated_information = { + "name": after.name, + "discriminator": int(after.discriminator), + } + # A 404 likely means the user is in another guild. + await self.patch_user(after.id, json=updated_information, ignore_404=True) + + @commands.group(name='sync') + @commands.has_permissions(administrator=True) + async def sync_group(self, ctx: Context) -> None: + """Run synchronizations between the bot and site manually.""" + + @sync_group.command(name='roles') + @commands.has_permissions(administrator=True) + async def sync_roles_command(self, ctx: Context) -> None: + """Manually synchronise the guild's roles with the roles on the site.""" + await self.role_syncer.sync(ctx.guild, ctx) + + @sync_group.command(name='users') + @commands.has_permissions(administrator=True) + async def sync_users_command(self, ctx: Context) -> None: + """Manually synchronise the guild's users with the users on the site.""" + await self.user_syncer.sync(ctx.guild, ctx) diff --git a/bot/cogs/backend/sync/syncers.py b/bot/cogs/backend/sync/syncers.py new file mode 100644 index 000000000..f7ba811bc --- /dev/null +++ b/bot/cogs/backend/sync/syncers.py @@ -0,0 +1,347 @@ +import abc +import asyncio +import logging +import typing as t +from collections import namedtuple +from functools import partial + +import discord +from discord import Guild, HTTPException, Member, Message, Reaction, User +from discord.ext.commands import Context + +from bot import constants +from bot.api import ResponseCodeError +from bot.bot import Bot + +log = logging.getLogger(__name__) + +# These objects are declared as namedtuples because tuples are hashable, +# something that we make use of when diffing site roles against guild roles. +_Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions', 'position')) +_User = namedtuple('User', ('id', 'name', 'discriminator', 'roles', 'in_guild')) +_Diff = namedtuple('Diff', ('created', 'updated', 'deleted')) + + +class Syncer(abc.ABC): + """Base class for synchronising the database with objects in the Discord cache.""" + + _CORE_DEV_MENTION = f"<@&{constants.Roles.core_developers}> " + _REACTION_EMOJIS = (constants.Emojis.check_mark, constants.Emojis.cross_mark) + + def __init__(self, bot: Bot) -> None: + self.bot = bot + + @property + @abc.abstractmethod + def name(self) -> str: + """The name of the syncer; used in output messages and logging.""" + raise NotImplementedError # pragma: no cover + + async def _send_prompt(self, message: t.Optional[Message] = None) -> t.Optional[Message]: + """ + Send a prompt to confirm or abort a sync using reactions and return the sent message. + + If a message is given, it is edited to display the prompt and reactions. Otherwise, a new + message is sent to the dev-core channel and mentions the core developers role. If the + channel cannot be retrieved, return None. + """ + log.trace(f"Sending {self.name} sync confirmation prompt.") + + msg_content = ( + f'Possible cache issue while syncing {self.name}s. ' + f'More than {constants.Sync.max_diff} {self.name}s were changed. ' + f'React to confirm or abort the sync.' + ) + + # Send to core developers if it's an automatic sync. + if not message: + log.trace("Message not provided for confirmation; creating a new one in dev-core.") + channel = self.bot.get_channel(constants.Channels.dev_core) + + if not channel: + log.debug("Failed to get the dev-core channel from cache; attempting to fetch it.") + try: + channel = await self.bot.fetch_channel(constants.Channels.dev_core) + except HTTPException: + log.exception( + f"Failed to fetch channel for sending sync confirmation prompt; " + f"aborting {self.name} sync." + ) + return None + + allowed_roles = [discord.Object(constants.Roles.core_developers)] + message = await channel.send( + f"{self._CORE_DEV_MENTION}{msg_content}", + allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) + ) + else: + await message.edit(content=msg_content) + + # Add the initial reactions. + log.trace(f"Adding reactions to {self.name} syncer confirmation prompt.") + for emoji in self._REACTION_EMOJIS: + await message.add_reaction(emoji) + + return message + + def _reaction_check( + self, + author: Member, + message: Message, + reaction: Reaction, + user: t.Union[Member, User] + ) -> bool: + """ + Return True if the `reaction` is a valid confirmation or abort reaction on `message`. + + If the `author` of the prompt is a bot, then a reaction by any core developer will be + considered valid. Otherwise, the author of the reaction (`user`) will have to be the + `author` of the prompt. + """ + # For automatic syncs, check for the core dev role instead of an exact author + has_role = any(constants.Roles.core_developers == role.id for role in user.roles) + return ( + reaction.message.id == message.id + and not user.bot + and (has_role if author.bot else user == author) + and str(reaction.emoji) in self._REACTION_EMOJIS + ) + + async def _wait_for_confirmation(self, author: Member, message: Message) -> bool: + """ + Wait for a confirmation reaction by `author` on `message` and return True if confirmed. + + Uses the `_reaction_check` function to determine if a reaction is valid. + + If there is no reaction within `bot.constants.Sync.confirm_timeout` seconds, return False. + To acknowledge the reaction (or lack thereof), `message` will be edited. + """ + # Preserve the core-dev role mention in the message edits so users aren't confused about + # where notifications came from. + mention = self._CORE_DEV_MENTION if author.bot else "" + + reaction = None + try: + log.trace(f"Waiting for a reaction to the {self.name} syncer confirmation prompt.") + reaction, _ = await self.bot.wait_for( + 'reaction_add', + check=partial(self._reaction_check, author, message), + timeout=constants.Sync.confirm_timeout + ) + except asyncio.TimeoutError: + # reaction will remain none thus sync will be aborted in the finally block below. + log.debug(f"The {self.name} syncer confirmation prompt timed out.") + + if str(reaction) == constants.Emojis.check_mark: + log.trace(f"The {self.name} syncer was confirmed.") + await message.edit(content=f':ok_hand: {mention}{self.name} sync will proceed.') + return True + else: + log.info(f"The {self.name} syncer was aborted or timed out!") + await message.edit( + content=f':warning: {mention}{self.name} sync aborted or timed out!' + ) + return False + + @abc.abstractmethod + async def _get_diff(self, guild: Guild) -> _Diff: + """Return the difference between the cache of `guild` and the database.""" + raise NotImplementedError # pragma: no cover + + @abc.abstractmethod + async def _sync(self, diff: _Diff) -> None: + """Perform the API calls for synchronisation.""" + raise NotImplementedError # pragma: no cover + + async def _get_confirmation_result( + self, + diff_size: int, + author: Member, + message: t.Optional[Message] = None + ) -> t.Tuple[bool, t.Optional[Message]]: + """ + Prompt for confirmation and return a tuple of the result and the prompt message. + + `diff_size` is the size of the diff of the sync. If it is greater than + `bot.constants.Sync.max_diff`, the prompt will be sent. The `author` is the invoked of the + sync and the `message` is an extant message to edit to display the prompt. + + If confirmed or no confirmation was needed, the result is True. The returned message will + either be the given `message` or a new one which was created when sending the prompt. + """ + log.trace(f"Determining if confirmation prompt should be sent for {self.name} syncer.") + if diff_size > constants.Sync.max_diff: + message = await self._send_prompt(message) + if not message: + return False, None # Couldn't get channel. + + confirmed = await self._wait_for_confirmation(author, message) + if not confirmed: + return False, message # Sync aborted. + + return True, message + + async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> None: + """ + Synchronise the database with the cache of `guild`. + + If the differences between the cache and the database are greater than + `bot.constants.Sync.max_diff`, then a confirmation prompt will be sent to the dev-core + channel. The confirmation can be optionally redirect to `ctx` instead. + """ + log.info(f"Starting {self.name} syncer.") + + message = None + author = self.bot.user + if ctx: + message = await ctx.send(f"📊 Synchronising {self.name}s.") + author = ctx.author + + diff = await self._get_diff(guild) + diff_dict = diff._asdict() # Ugly method for transforming the NamedTuple into a dict + totals = {k: len(v) for k, v in diff_dict.items() if v is not None} + diff_size = sum(totals.values()) + + confirmed, message = await self._get_confirmation_result(diff_size, author, message) + if not confirmed: + return + + # Preserve the core-dev role mention in the message edits so users aren't confused about + # where notifications came from. + mention = self._CORE_DEV_MENTION if author.bot else "" + + try: + await self._sync(diff) + except ResponseCodeError as e: + log.exception(f"{self.name} syncer failed!") + + # Don't show response text because it's probably some really long HTML. + results = f"status {e.status}\n```{e.response_json or 'See log output for details'}```" + content = f":x: {mention}Synchronisation of {self.name}s failed: {results}" + else: + results = ", ".join(f"{name} `{total}`" for name, total in totals.items()) + log.info(f"{self.name} syncer finished: {results}.") + content = f":ok_hand: {mention}Synchronisation of {self.name}s complete: {results}" + + if message: + await message.edit(content=content) + + +class RoleSyncer(Syncer): + """Synchronise the database with roles in the cache.""" + + name = "role" + + async def _get_diff(self, guild: Guild) -> _Diff: + """Return the difference of roles between the cache of `guild` and the database.""" + log.trace("Getting the diff for roles.") + roles = await self.bot.api_client.get('bot/roles') + + # Pack DB roles and guild roles into one common, hashable format. + # They're hashable so that they're easily comparable with sets later. + db_roles = {_Role(**role_dict) for role_dict in roles} + guild_roles = { + _Role( + id=role.id, + name=role.name, + colour=role.colour.value, + permissions=role.permissions.value, + position=role.position, + ) + for role in guild.roles + } + + guild_role_ids = {role.id for role in guild_roles} + api_role_ids = {role.id for role in db_roles} + new_role_ids = guild_role_ids - api_role_ids + deleted_role_ids = api_role_ids - guild_role_ids + + # New roles are those which are on the cached guild but not on the + # DB guild, going by the role ID. We need to send them in for creation. + roles_to_create = {role for role in guild_roles if role.id in new_role_ids} + roles_to_update = guild_roles - db_roles - roles_to_create + roles_to_delete = {role for role in db_roles if role.id in deleted_role_ids} + + return _Diff(roles_to_create, roles_to_update, roles_to_delete) + + async def _sync(self, diff: _Diff) -> None: + """Synchronise the database with the role cache of `guild`.""" + log.trace("Syncing created roles...") + for role in diff.created: + await self.bot.api_client.post('bot/roles', json=role._asdict()) + + log.trace("Syncing updated roles...") + for role in diff.updated: + await self.bot.api_client.put(f'bot/roles/{role.id}', json=role._asdict()) + + log.trace("Syncing deleted roles...") + for role in diff.deleted: + await self.bot.api_client.delete(f'bot/roles/{role.id}') + + +class UserSyncer(Syncer): + """Synchronise the database with users in the cache.""" + + name = "user" + + async def _get_diff(self, guild: Guild) -> _Diff: + """Return the difference of users between the cache of `guild` and the database.""" + log.trace("Getting the diff for users.") + users = await self.bot.api_client.get('bot/users') + + # Pack DB roles and guild roles into one common, hashable format. + # They're hashable so that they're easily comparable with sets later. + db_users = { + user_dict['id']: _User( + roles=tuple(sorted(user_dict.pop('roles'))), + **user_dict + ) + for user_dict in users + } + guild_users = { + member.id: _User( + id=member.id, + name=member.name, + discriminator=int(member.discriminator), + roles=tuple(sorted(role.id for role in member.roles)), + in_guild=True + ) + for member in guild.members + } + + users_to_create = set() + users_to_update = set() + + for db_user in db_users.values(): + guild_user = guild_users.get(db_user.id) + if guild_user is not None: + if db_user != guild_user: + users_to_update.add(guild_user) + + elif db_user.in_guild: + # The user is known in the DB but not the guild, and the + # DB currently specifies that the user is a member of the guild. + # This means that the user has left since the last sync. + # Update the `in_guild` attribute of the user on the site + # to signify that the user left. + new_api_user = db_user._replace(in_guild=False) + users_to_update.add(new_api_user) + + new_user_ids = set(guild_users.keys()) - set(db_users.keys()) + for user_id in new_user_ids: + # The user is known on the guild but not on the API. This means + # that the user has joined since the last sync. Create it. + new_user = guild_users[user_id] + users_to_create.add(new_user) + + return _Diff(users_to_create, users_to_update, None) + + async def _sync(self, diff: _Diff) -> None: + """Synchronise the database with the user cache of `guild`.""" + log.trace("Syncing created users...") + for user in diff.created: + await self.bot.api_client.post('bot/users', json=user._asdict()) + + log.trace("Syncing updated users...") + for user in diff.updated: + await self.bot.api_client.put(f'bot/users/{user.id}', json=user._asdict()) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py deleted file mode 100644 index 79510739c..000000000 --- a/bot/cogs/bot.py +++ /dev/null @@ -1,385 +0,0 @@ -import ast -import logging -import re -import time -from typing import Optional, Tuple - -from discord import Embed, Message, RawMessageUpdateEvent, TextChannel -from discord.ext.commands import Cog, Context, command, group - -from bot.bot import Bot -from bot.cogs.token_remover import TokenRemover -from bot.constants import Categories, Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs -from bot.decorators import with_role -from bot.utils.messages import wait_for_deletion - -log = logging.getLogger(__name__) - -RE_MARKDOWN = re.compile(r'([*_~`|>])') - - -class BotCog(Cog, name="Bot"): - """Bot information commands.""" - - def __init__(self, bot: Bot): - self.bot = bot - - # Stores allowed channels plus epoch time since last call. - self.channel_cooldowns = { - Channels.python_discussion: 0, - } - - # These channels will also work, but will not be subject to cooldown - self.channel_whitelist = ( - Channels.bot_commands, - ) - - # Stores improperly formatted Python codeblock message ids and the corresponding bot message - self.codeblock_message_ids = {} - - @group(invoke_without_command=True, name="bot", hidden=True) - @with_role(Roles.verified) - async def botinfo_group(self, ctx: Context) -> None: - """Bot informational commands.""" - await ctx.send_help(ctx.command) - - @botinfo_group.command(name='about', aliases=('info',), hidden=True) - @with_role(Roles.verified) - async def about_command(self, ctx: Context) -> None: - """Get information about the bot.""" - embed = Embed( - description="A utility bot designed just for the Python server! Try `!help` for more info.", - url="https://github.com/python-discord/bot" - ) - - embed.add_field(name="Total Users", value=str(len(self.bot.get_guild(Guild.id).members))) - embed.set_author( - name="Python Bot", - url="https://github.com/python-discord/bot", - icon_url=URLs.bot_avatar - ) - - await ctx.send(embed=embed) - - @command(name='echo', aliases=('print',)) - @with_role(*MODERATION_ROLES) - async def echo_command(self, ctx: Context, channel: Optional[TextChannel], *, text: str) -> None: - """Repeat the given message in either a specified channel or the current channel.""" - if channel is None: - await ctx.send(text) - else: - await channel.send(text) - - @command(name='embed') - @with_role(*MODERATION_ROLES) - async def embed_command(self, ctx: Context, channel: Optional[TextChannel], *, text: str) -> None: - """Send the input within an embed to either a specified channel or the current channel.""" - embed = Embed(description=text) - - if channel is None: - await ctx.send(embed=embed) - else: - await channel.send(embed=embed) - - def codeblock_stripping(self, msg: str, bad_ticks: bool) -> Optional[Tuple[Tuple[str, ...], str]]: - """ - Strip msg in order to find Python code. - - Tries to strip out Python code out of msg and returns the stripped block or - None if the block is a valid Python codeblock. - """ - if msg.count("\n") >= 3: - # Filtering valid Python codeblocks and exiting if a valid Python codeblock is found. - if re.search("```(?:py|python)\n(.*?)```", msg, re.IGNORECASE | re.DOTALL) and not bad_ticks: - log.trace( - "Someone wrote a message that was already a " - "valid Python syntax highlighted code block. No action taken." - ) - return None - - else: - # Stripping backticks from every line of the message. - log.trace(f"Stripping backticks from message.\n\n{msg}\n\n") - content = "" - for line in msg.splitlines(keepends=True): - content += line.strip("`") - - content = content.strip() - - # Remove "Python" or "Py" from start of the message if it exists. - log.trace(f"Removing 'py' or 'python' from message.\n\n{content}\n\n") - pycode = False - if content.lower().startswith("python"): - content = content[6:] - pycode = True - elif content.lower().startswith("py"): - content = content[2:] - pycode = True - - if pycode: - content = content.splitlines(keepends=True) - - # Check if there might be code in the first line, and preserve it. - first_line = content[0] - if " " in content[0]: - first_space = first_line.index(" ") - content[0] = first_line[first_space:] - content = "".join(content) - - # If there's no code we can just get rid of the first line. - else: - content = "".join(content[1:]) - - # Strip it again to remove any leading whitespace. This is neccessary - # if the first line of the message looked like ```python - old = content.strip() - - # Strips REPL code out of the message if there is any. - content, repl_code = self.repl_stripping(old) - if old != content: - return (content, old), repl_code - - # Try to apply indentation fixes to the code. - content = self.fix_indentation(content) - - # Check if the code contains backticks, if it does ignore the message. - if "`" in content: - log.trace("Detected ` inside the code, won't reply") - return None - else: - log.trace(f"Returning message.\n\n{content}\n\n") - return (content,), repl_code - - def fix_indentation(self, msg: str) -> str: - """Attempts to fix badly indented code.""" - def unindent(code: str, skip_spaces: int = 0) -> str: - """Unindents all code down to the number of spaces given in skip_spaces.""" - final = "" - current = code[0] - leading_spaces = 0 - - # Get numbers of spaces before code in the first line. - while current == " ": - current = code[leading_spaces + 1] - leading_spaces += 1 - leading_spaces -= skip_spaces - - # If there are any, remove that number of spaces from every line. - if leading_spaces > 0: - for line in code.splitlines(keepends=True): - line = line[leading_spaces:] - final += line - return final - else: - return code - - # Apply fix for "all lines are overindented" case. - msg = unindent(msg) - - # If the first line does not end with a colon, we can be - # certain the next line will be on the same indentation level. - # - # If it does end with a colon, we will need to indent all successive - # lines one additional level. - first_line = msg.splitlines()[0] - code = "".join(msg.splitlines(keepends=True)[1:]) - if not first_line.endswith(":"): - msg = f"{first_line}\n{unindent(code)}" - else: - msg = f"{first_line}\n{unindent(code, 4)}" - return msg - - def repl_stripping(self, msg: str) -> Tuple[str, bool]: - """ - Strip msg in order to extract Python code out of REPL output. - - Tries to strip out REPL Python code out of msg and returns the stripped msg. - - Returns True for the boolean if REPL code was found in the input msg. - """ - final = "" - for line in msg.splitlines(keepends=True): - if line.startswith(">>>") or line.startswith("..."): - final += line[4:] - log.trace(f"Formatted: \n\n{msg}\n\n to \n\n{final}\n\n") - if not final: - log.trace(f"Found no REPL code in \n\n{msg}\n\n") - return msg, False - else: - log.trace(f"Found REPL code in \n\n{msg}\n\n") - return final.rstrip(), True - - def has_bad_ticks(self, msg: Message) -> bool: - """Check to see if msg contains ticks that aren't '`'.""" - not_backticks = [ - "'''", '"""', "\u00b4\u00b4\u00b4", "\u2018\u2018\u2018", "\u2019\u2019\u2019", - "\u2032\u2032\u2032", "\u201c\u201c\u201c", "\u201d\u201d\u201d", "\u2033\u2033\u2033", - "\u3003\u3003\u3003" - ] - - return msg.content[:3] in not_backticks - - @Cog.listener() - async def on_message(self, msg: Message) -> None: - """ - Detect poorly formatted Python code in new messages. - - If poorly formatted code is detected, send the user a helpful message explaining how to do - properly formatted Python syntax highlighting codeblocks. - """ - is_help_channel = ( - getattr(msg.channel, "category", None) - and msg.channel.category.id in (Categories.help_available, Categories.help_in_use) - ) - parse_codeblock = ( - ( - is_help_channel - or msg.channel.id in self.channel_cooldowns - or msg.channel.id in self.channel_whitelist - ) - and not msg.author.bot - and len(msg.content.splitlines()) > 3 - and not TokenRemover.find_token_in_message(msg) - ) - - if parse_codeblock: # no token in the msg - on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300 - if not on_cooldown or DEBUG_MODE: - try: - if self.has_bad_ticks(msg): - ticks = msg.content[:3] - content = self.codeblock_stripping(f"```{msg.content[3:-3]}```", True) - if content is None: - return - - content, repl_code = content - - if len(content) == 2: - content = content[1] - else: - content = content[0] - - space_left = 204 - if len(content) >= space_left: - current_length = 0 - lines_walked = 0 - for line in content.splitlines(keepends=True): - if current_length + len(line) > space_left or lines_walked == 10: - break - current_length += len(line) - lines_walked += 1 - content = content[:current_length] + "#..." - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) - howto = ( - "It looks like you are trying to paste code into this channel.\n\n" - "You seem to be using the wrong symbols to indicate where the codeblock should start. " - f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n" - "**Here is an example of how it should look:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) - - else: - howto = "" - content = self.codeblock_stripping(msg.content, False) - if content is None: - return - - content, repl_code = content - # Attempts to parse the message into an AST node. - # Invalid Python code will raise a SyntaxError. - tree = ast.parse(content[0]) - - # Multiple lines of single words could be interpreted as expressions. - # This check is to avoid all nodes being parsed as expressions. - # (e.g. words over multiple lines) - if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: - # Shorten the code to 10 lines and/or 204 characters. - space_left = 204 - if content and repl_code: - content = content[1] - else: - content = content[0] - - if len(content) >= space_left: - current_length = 0 - lines_walked = 0 - for line in content.splitlines(keepends=True): - if current_length + len(line) > space_left or lines_walked == 10: - break - current_length += len(line) - lines_walked += 1 - content = content[:current_length] + "#..." - - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) - howto += ( - "It looks like you're trying to paste code into this channel.\n\n" - "Discord has support for Markdown, which allows you to post code with full " - "syntax highlighting. Please use these whenever you paste code, as this " - "helps improve the legibility and makes it easier for us to help you.\n\n" - f"**To do this, use the following method:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) - - log.debug(f"{msg.author} posted something that needed to be put inside python code " - "blocks. Sending the user some instructions.") - else: - log.trace("The code consists only of expressions, not sending instructions") - - if howto != "": - # Increase amount of codeblock correction in stats - self.bot.stats.incr("codeblock_corrections") - howto_embed = Embed(description=howto) - bot_message = await msg.channel.send(f"Hey {msg.author.mention}!", embed=howto_embed) - self.codeblock_message_ids[msg.id] = bot_message.id - - self.bot.loop.create_task( - wait_for_deletion(bot_message, user_ids=(msg.author.id,), client=self.bot) - ) - else: - return - - if msg.channel.id not in self.channel_whitelist: - self.channel_cooldowns[msg.channel.id] = time.time() - - except SyntaxError: - log.trace( - f"{msg.author} posted in a help channel, and when we tried to parse it as Python code, " - "ast.parse raised a SyntaxError. This probably just means it wasn't Python code. " - f"The message that was posted was:\n\n{msg.content}\n\n" - ) - - @Cog.listener() - async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: - """Check to see if an edited message (previously called out) still contains poorly formatted code.""" - if ( - # Checks to see if the message was called out by the bot - payload.message_id not in self.codeblock_message_ids - # Makes sure that there is content in the message - or payload.data.get("content") is None - # Makes sure there's a channel id in the message payload - or payload.data.get("channel_id") is None - ): - return - - # Retrieve channel and message objects for use later - channel = self.bot.get_channel(int(payload.data.get("channel_id"))) - user_message = await channel.fetch_message(payload.message_id) - - # Checks to see if the user has corrected their codeblock. If it's fixed, has_fixed_codeblock will be None - has_fixed_codeblock = self.codeblock_stripping(payload.data.get("content"), self.has_bad_ticks(user_message)) - - # If the message is fixed, delete the bot message and the entry from the id dictionary - if has_fixed_codeblock is None: - bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) - await bot_message.delete() - del self.codeblock_message_ids[payload.message_id] - log.trace("User's incorrect code block has been fixed. Removing bot formatting message.") - - -def setup(bot: Bot) -> None: - """Load the Bot cog.""" - bot.add_cog(BotCog(bot)) diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py deleted file mode 100644 index f436e531a..000000000 --- a/bot/cogs/clean.py +++ /dev/null @@ -1,272 +0,0 @@ -import logging -import random -import re -from typing import Iterable, Optional - -from discord import Colour, Embed, Message, TextChannel, User -from discord.ext import commands -from discord.ext.commands import Cog, Context, group - -from bot.bot import Bot -from bot.cogs.moderation import ModLog -from bot.constants import ( - Channels, CleanMessages, Colours, Event, Icons, MODERATION_ROLES, NEGATIVE_REPLIES -) -from bot.decorators import with_role - -log = logging.getLogger(__name__) - - -class Clean(Cog): - """ - A cog that allows messages to be deleted in bulk, while applying various filters. - - You can delete messages sent by a specific user, messages sent by bots, all messages, or messages that match a - specific regular expression. - - The deleted messages are saved and uploaded to the database via an API endpoint, and a URL is returned which can be - used to view the messages in the Discord dark theme style. - """ - - def __init__(self, bot: Bot): - self.bot = bot - self.cleaning = False - - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - async def _clean_messages( - self, - amount: int, - ctx: Context, - channels: Iterable[TextChannel], - bots_only: bool = False, - user: User = None, - regex: Optional[str] = None, - until_message: Optional[Message] = None, - ) -> None: - """A helper function that does the actual message cleaning.""" - def predicate_bots_only(message: Message) -> bool: - """Return True if the message was sent by a bot.""" - return message.author.bot - - def predicate_specific_user(message: Message) -> bool: - """Return True if the message was sent by the user provided in the _clean_messages call.""" - return message.author == user - - def predicate_regex(message: Message) -> bool: - """Check if the regex provided in _clean_messages matches the message content or any embed attributes.""" - content = [message.content] - - # Add the content for all embed attributes - for embed in message.embeds: - content.append(embed.title) - content.append(embed.description) - content.append(embed.footer.text) - content.append(embed.author.name) - for field in embed.fields: - content.append(field.name) - content.append(field.value) - - # Get rid of empty attributes and turn it into a string - content = [attr for attr in content if attr] - content = "\n".join(content) - - # Now let's see if there's a regex match - if not content: - return False - else: - return bool(re.search(regex.lower(), content.lower())) - - # Is this an acceptable amount of messages to clean? - if amount > CleanMessages.message_limit: - embed = Embed( - color=Colour(Colours.soft_red), - title=random.choice(NEGATIVE_REPLIES), - description=f"You cannot clean more than {CleanMessages.message_limit} messages." - ) - await ctx.send(embed=embed) - return - - # Are we already performing a clean? - if self.cleaning: - embed = Embed( - color=Colour(Colours.soft_red), - title=random.choice(NEGATIVE_REPLIES), - description="Please wait for the currently ongoing clean operation to complete." - ) - await ctx.send(embed=embed) - return - - # Set up the correct predicate - if bots_only: - predicate = predicate_bots_only # Delete messages from bots - elif user: - predicate = predicate_specific_user # Delete messages from specific user - elif regex: - predicate = predicate_regex # Delete messages that match regex - else: - predicate = None # Delete all messages - - # Default to using the invoking context's channel - if not channels: - channels = [ctx.channel] - - # Delete the invocation first - self.mod_log.ignore(Event.message_delete, ctx.message.id) - await ctx.message.delete() - - messages = [] - message_ids = [] - self.cleaning = True - - # Find the IDs of the messages to delete. IDs are needed in order to ignore mod log events. - for channel in channels: - async for message in channel.history(limit=amount): - - # If at any point the cancel command is invoked, we should stop. - if not self.cleaning: - return - - # If we are looking for specific message. - if until_message: - - # we could use ID's here however in case if the message we are looking for gets deleted, - # we won't have a way to figure that out thus checking for datetime should be more reliable - if message.created_at < until_message.created_at: - # means we have found the message until which we were supposed to be deleting. - break - - # Since we will be using `delete_messages` method of a TextChannel and we need message objects to - # use it as well as to send logs we will start appending messages here instead adding them from - # purge. - messages.append(message) - - # If the message passes predicate, let's save it. - if predicate is None or predicate(message): - message_ids.append(message.id) - - self.cleaning = False - - # Now let's delete the actual messages with purge. - self.mod_log.ignore(Event.message_delete, *message_ids) - for channel in channels: - if until_message: - for i in range(0, len(messages), 100): - # while purge automatically handles the amount of messages - # delete_messages only allows for up to 100 messages at once - # thus we need to paginate the amount to always be <= 100 - await channel.delete_messages(messages[i:i + 100]) - else: - messages += await channel.purge(limit=amount, check=predicate) - - # Reverse the list to restore chronological order - if messages: - messages = reversed(messages) - log_url = await self.mod_log.upload_log(messages, ctx.author.id) - else: - # Can't build an embed, nothing to clean! - embed = Embed( - color=Colour(Colours.soft_red), - description="No matching messages could be found." - ) - await ctx.send(embed=embed, delete_after=10) - return - - # Build the embed and send it - target_channels = ", ".join(channel.mention for channel in channels) - - message = ( - f"**{len(message_ids)}** messages deleted in {target_channels} by **{ctx.author.name}**\n\n" - f"A log of the deleted messages can be found [here]({log_url})." - ) - - await self.mod_log.send_log_message( - icon_url=Icons.message_bulk_delete, - colour=Colour(Colours.soft_red), - title="Bulk message delete", - text=message, - channel_id=Channels.mod_log, - ) - - @group(invoke_without_command=True, name="clean", aliases=["purge"]) - @with_role(*MODERATION_ROLES) - async def clean_group(self, ctx: Context) -> None: - """Commands for cleaning messages in channels.""" - await ctx.send_help(ctx.command) - - @clean_group.command(name="user", aliases=["users"]) - @with_role(*MODERATION_ROLES) - async def clean_user( - self, - ctx: Context, - user: User, - amount: Optional[int] = 10, - channels: commands.Greedy[TextChannel] = None - ) -> None: - """Delete messages posted by the provided user, stop cleaning after traversing `amount` messages.""" - await self._clean_messages(amount, ctx, user=user, channels=channels) - - @clean_group.command(name="all", aliases=["everything"]) - @with_role(*MODERATION_ROLES) - async def clean_all( - self, - ctx: Context, - amount: Optional[int] = 10, - channels: commands.Greedy[TextChannel] = None - ) -> None: - """Delete all messages, regardless of poster, stop cleaning after traversing `amount` messages.""" - await self._clean_messages(amount, ctx, channels=channels) - - @clean_group.command(name="bots", aliases=["bot"]) - @with_role(*MODERATION_ROLES) - async def clean_bots( - self, - ctx: Context, - amount: Optional[int] = 10, - channels: commands.Greedy[TextChannel] = None - ) -> None: - """Delete all messages posted by a bot, stop cleaning after traversing `amount` messages.""" - await self._clean_messages(amount, ctx, bots_only=True, channels=channels) - - @clean_group.command(name="regex", aliases=["word", "expression"]) - @with_role(*MODERATION_ROLES) - async def clean_regex( - self, - ctx: Context, - regex: str, - amount: Optional[int] = 10, - channels: commands.Greedy[TextChannel] = None - ) -> None: - """Delete all messages that match a certain regex, stop cleaning after traversing `amount` messages.""" - await self._clean_messages(amount, ctx, regex=regex, channels=channels) - - @clean_group.command(name="message", aliases=["messages"]) - @with_role(*MODERATION_ROLES) - async def clean_message(self, ctx: Context, message: Message) -> None: - """Delete all messages until certain message, stop cleaning after hitting the `message`.""" - await self._clean_messages( - CleanMessages.message_limit, - ctx, - channels=[message.channel], - until_message=message - ) - - @clean_group.command(name="stop", aliases=["cancel", "abort"]) - @with_role(*MODERATION_ROLES) - async def clean_cancel(self, ctx: Context) -> None: - """If there is an ongoing cleaning process, attempt to immediately cancel it.""" - self.cleaning = False - - embed = Embed( - color=Colour.blurple(), - description="Clean interrupted." - ) - await ctx.send(embed=embed, delete_after=10) - - -def setup(bot: Bot) -> None: - """Load the Clean cog.""" - bot.add_cog(Clean(bot)) diff --git a/bot/cogs/config_verifier.py b/bot/cogs/config_verifier.py deleted file mode 100644 index d72c6c22e..000000000 --- a/bot/cogs/config_verifier.py +++ /dev/null @@ -1,40 +0,0 @@ -import logging - -from discord.ext.commands import Cog - -from bot import constants -from bot.bot import Bot - - -log = logging.getLogger(__name__) - - -class ConfigVerifier(Cog): - """Verify config on startup.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.channel_verify_task = self.bot.loop.create_task(self.verify_channels()) - - async def verify_channels(self) -> None: - """ - Verify channels. - - If any channels in config aren't present in server, log them in a warning. - """ - await self.bot.wait_until_guild_available() - server = self.bot.get_guild(constants.Guild.id) - - server_channel_ids = {channel.id for channel in server.channels} - invalid_channels = [ - channel_name for channel_name, channel_id in constants.Channels - if channel_id not in server_channel_ids - ] - - if invalid_channels: - log.warning(f"Configured channels do not exist in server: {', '.join(invalid_channels)}.") - - -def setup(bot: Bot) -> None: - """Load the ConfigVerifier cog.""" - bot.add_cog(ConfigVerifier(bot)) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py deleted file mode 100644 index 4c0ad5914..000000000 --- a/bot/cogs/defcon.py +++ /dev/null @@ -1,258 +0,0 @@ -from __future__ import annotations - -import logging -from collections import namedtuple -from datetime import datetime, timedelta -from enum import Enum - -from discord import Colour, Embed, Member -from discord.ext.commands import Cog, Context, group - -from bot.bot import Bot -from bot.cogs.moderation import ModLog -from bot.constants import Channels, Colours, Emojis, Event, Icons, Roles -from bot.decorators import with_role - -log = logging.getLogger(__name__) - -REJECTION_MESSAGE = """ -Hi, {user} - Thanks for your interest in our server! - -Due to a current (or detected) cyberattack on our community, we've limited access to the server for new accounts. Since -your account is relatively new, we're unable to provide access to the server at this time. - -Even so, thanks for joining! We're very excited at the possibility of having you here, and we hope that this situation -will be resolved soon. In the meantime, please feel free to peruse the resources on our site at -, and have a nice day! -""" - -BASE_CHANNEL_TOPIC = "Python Discord Defense Mechanism" - - -class Action(Enum): - """Defcon Action.""" - - ActionInfo = namedtuple('LogInfoDetails', ['icon', 'color', 'template']) - - ENABLED = ActionInfo(Icons.defcon_enabled, Colours.soft_green, "**Days:** {days}\n\n") - DISABLED = ActionInfo(Icons.defcon_disabled, Colours.soft_red, "") - UPDATED = ActionInfo(Icons.defcon_updated, Colour.blurple(), "**Days:** {days}\n\n") - - -class Defcon(Cog): - """Time-sensitive server defense mechanisms.""" - - days = None # type: timedelta - enabled = False # type: bool - - def __init__(self, bot: Bot): - self.bot = bot - self.channel = None - self.days = timedelta(days=0) - - self.bot.loop.create_task(self.sync_settings()) - - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - async def sync_settings(self) -> None: - """On cog load, try to synchronize DEFCON settings to the API.""" - await self.bot.wait_until_guild_available() - self.channel = await self.bot.fetch_channel(Channels.defcon) - - try: - response = await self.bot.api_client.get('bot/bot-settings/defcon') - data = response['data'] - - except Exception: # Yikes! - log.exception("Unable to get DEFCON settings!") - await self.bot.get_channel(Channels.dev_log).send( - f"<@&{Roles.admins}> **WARNING**: Unable to get DEFCON settings!" - ) - - else: - if data["enabled"]: - self.enabled = True - self.days = timedelta(days=data["days"]) - log.info(f"DEFCON enabled: {self.days.days} days") - - else: - self.enabled = False - self.days = timedelta(days=0) - log.info("DEFCON disabled") - - await self.update_channel_topic() - - @Cog.listener() - async def on_member_join(self, member: Member) -> None: - """If DEFCON is enabled, check newly joining users to see if they meet the account age threshold.""" - if self.enabled and self.days.days > 0: - now = datetime.utcnow() - - if now - member.created_at < self.days: - log.info(f"Rejecting user {member}: Account is too new and DEFCON is enabled") - - message_sent = False - - try: - await member.send(REJECTION_MESSAGE.format(user=member.mention)) - - message_sent = True - except Exception: - log.exception(f"Unable to send rejection message to user: {member}") - - await member.kick(reason="DEFCON active, user is too new") - self.bot.stats.incr("defcon.leaves") - - message = ( - f"{member} (`{member.id}`) was denied entry because their account is too new." - ) - - if not message_sent: - message = f"{message}\n\nUnable to send rejection message via DM; they probably have DMs disabled." - - await self.mod_log.send_log_message( - Icons.defcon_denied, Colours.soft_red, "Entry denied", - message, member.avatar_url_as(static_format="png") - ) - - @group(name='defcon', aliases=('dc',), invoke_without_command=True) - @with_role(Roles.admins, Roles.owners) - async def defcon_group(self, ctx: Context) -> None: - """Check the DEFCON status or run a subcommand.""" - await ctx.send_help(ctx.command) - - async def _defcon_action(self, ctx: Context, days: int, action: Action) -> None: - """Providing a structured way to do an defcon action.""" - try: - response = await self.bot.api_client.get('bot/bot-settings/defcon') - data = response['data'] - - if "enable_date" in data and action is Action.DISABLED: - enabled = datetime.fromisoformat(data["enable_date"]) - - delta = datetime.now() - enabled - - self.bot.stats.timing("defcon.enabled", delta) - except Exception: - pass - - error = None - try: - await self.bot.api_client.put( - 'bot/bot-settings/defcon', - json={ - 'name': 'defcon', - 'data': { - # TODO: retrieve old days count - 'days': days, - 'enabled': action is not Action.DISABLED, - 'enable_date': datetime.now().isoformat() - } - } - ) - except Exception as err: - log.exception("Unable to update DEFCON settings.") - error = err - finally: - await ctx.send(self.build_defcon_msg(action, error)) - await self.send_defcon_log(action, ctx.author, error) - - self.bot.stats.gauge("defcon.threshold", days) - - @defcon_group.command(name='enable', aliases=('on', 'e')) - @with_role(Roles.admins, Roles.owners) - async def enable_command(self, ctx: Context) -> None: - """ - Enable DEFCON mode. Useful in a pinch, but be sure you know what you're doing! - - Currently, this just adds an account age requirement. Use !defcon days to set how old an account must be, - in days. - """ - self.enabled = True - await self._defcon_action(ctx, days=0, action=Action.ENABLED) - await self.update_channel_topic() - - @defcon_group.command(name='disable', aliases=('off', 'd')) - @with_role(Roles.admins, Roles.owners) - async def disable_command(self, ctx: Context) -> None: - """Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!""" - self.enabled = False - await self._defcon_action(ctx, days=0, action=Action.DISABLED) - await self.update_channel_topic() - - @defcon_group.command(name='status', aliases=('s',)) - @with_role(Roles.admins, Roles.owners) - async def status_command(self, ctx: Context) -> None: - """Check the current status of DEFCON mode.""" - embed = Embed( - colour=Colour.blurple(), title="DEFCON Status", - description=f"**Enabled:** {self.enabled}\n" - f"**Days:** {self.days.days}" - ) - - await ctx.send(embed=embed) - - @defcon_group.command(name='days') - @with_role(Roles.admins, Roles.owners) - async def days_command(self, ctx: Context, days: int) -> None: - """Set how old an account must be to join the server, in days, with DEFCON mode enabled.""" - self.days = timedelta(days=days) - self.enabled = True - await self._defcon_action(ctx, days=days, action=Action.UPDATED) - await self.update_channel_topic() - - async def update_channel_topic(self) -> None: - """Update the #defcon channel topic with the current DEFCON status.""" - if self.enabled: - day_str = "days" if self.days.days > 1 else "day" - new_topic = f"{BASE_CHANNEL_TOPIC}\n(Status: Enabled, Threshold: {self.days.days} {day_str})" - else: - new_topic = f"{BASE_CHANNEL_TOPIC}\n(Status: Disabled)" - - self.mod_log.ignore(Event.guild_channel_update, Channels.defcon) - await self.channel.edit(topic=new_topic) - - def build_defcon_msg(self, action: Action, e: Exception = None) -> str: - """Build in-channel response string for DEFCON action.""" - if action is Action.ENABLED: - msg = f"{Emojis.defcon_enabled} DEFCON enabled.\n\n" - elif action is Action.DISABLED: - msg = f"{Emojis.defcon_disabled} DEFCON disabled.\n\n" - elif action is Action.UPDATED: - msg = ( - f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {self.days.days} " - f"day{'s' if self.days.days > 1 else ''} old to join the server.\n\n" - ) - - if e: - msg += ( - "**There was a problem updating the site** - This setting may be reverted when the bot restarts.\n\n" - f"```py\n{e}\n```" - ) - - return msg - - async def send_defcon_log(self, action: Action, actor: Member, e: Exception = None) -> None: - """Send log message for DEFCON action.""" - info = action.value - log_msg: str = ( - f"**Staffer:** {actor.mention} {actor} (`{actor.id}`)\n" - f"{info.template.format(days=self.days.days)}" - ) - status_msg = f"DEFCON {action.name.lower()}" - - if e: - log_msg += ( - "**There was a problem updating the site** - This setting may be reverted when the bot restarts.\n\n" - f"```py\n{e}\n```" - ) - - await self.mod_log.send_log_message(info.icon, info.color, status_msg, log_msg) - - -def setup(bot: Bot) -> None: - """Load the Defcon cog.""" - bot.add_cog(Defcon(bot)) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py deleted file mode 100644 index 204cffb37..000000000 --- a/bot/cogs/doc.py +++ /dev/null @@ -1,511 +0,0 @@ -import asyncio -import functools -import logging -import re -import textwrap -from collections import OrderedDict -from contextlib import suppress -from types import SimpleNamespace -from typing import Any, Callable, Optional, Tuple - -import discord -from bs4 import BeautifulSoup -from bs4.element import PageElement, Tag -from discord.errors import NotFound -from discord.ext import commands -from markdownify import MarkdownConverter -from requests import ConnectTimeout, ConnectionError, HTTPError -from sphinx.ext import intersphinx -from urllib3.exceptions import ProtocolError - -from bot.bot import Bot -from bot.constants import MODERATION_ROLES, RedirectOutput -from bot.converters import ValidPythonIdentifier, ValidURL -from bot.decorators import with_role -from bot.pagination import LinePaginator - - -log = logging.getLogger(__name__) -logging.getLogger('urllib3').setLevel(logging.WARNING) - -# Since Intersphinx is intended to be used with Sphinx, -# we need to mock its configuration. -SPHINX_MOCK_APP = SimpleNamespace( - config=SimpleNamespace( - intersphinx_timeout=3, - tls_verify=True, - user_agent="python3:python-discord/bot:1.0.0" - ) -) - -NO_OVERRIDE_GROUPS = ( - "2to3fixer", - "token", - "label", - "pdbcommand", - "term", -) -NO_OVERRIDE_PACKAGES = ( - "python", -) - -SEARCH_END_TAG_ATTRS = ( - "data", - "function", - "class", - "exception", - "seealso", - "section", - "rubric", - "sphinxsidebar", -) -UNWANTED_SIGNATURE_SYMBOLS_RE = re.compile(r"\[source]|\\\\|¶") -WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)") - -FAILED_REQUEST_RETRY_AMOUNT = 3 -NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay - - -def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable: - """ - LRU cache implementation for coroutines. - - Once the cache exceeds the maximum size, keys are deleted in FIFO order. - - An offset may be optionally provided to be applied to the coroutine's arguments when creating the cache key. - """ - # Assign the cache to the function itself so we can clear it from outside. - async_cache.cache = OrderedDict() - - def decorator(function: Callable) -> Callable: - """Define the async_cache decorator.""" - @functools.wraps(function) - async def wrapper(*args) -> Any: - """Decorator wrapper for the caching logic.""" - key = ':'.join(args[arg_offset:]) - - value = async_cache.cache.get(key) - if value is None: - if len(async_cache.cache) > max_size: - async_cache.cache.popitem(last=False) - - async_cache.cache[key] = await function(*args) - return async_cache.cache[key] - return wrapper - return decorator - - -class DocMarkdownConverter(MarkdownConverter): - """Subclass markdownify's MarkdownCoverter to provide custom conversion methods.""" - - def convert_code(self, el: PageElement, text: str) -> str: - """Undo `markdownify`s underscore escaping.""" - return f"`{text}`".replace('\\', '') - - def convert_pre(self, el: PageElement, text: str) -> str: - """Wrap any codeblocks in `py` for syntax highlighting.""" - code = ''.join(el.strings) - return f"```py\n{code}```" - - -def markdownify(html: str) -> DocMarkdownConverter: - """Create a DocMarkdownConverter object from the input html.""" - return DocMarkdownConverter(bullets='•').convert(html) - - -class InventoryURL(commands.Converter): - """ - Represents an Intersphinx inventory URL. - - This converter checks whether intersphinx accepts the given inventory URL, and raises - `BadArgument` if that is not the case. - - Otherwise, it simply passes through the given URL. - """ - - @staticmethod - async def convert(ctx: commands.Context, url: str) -> str: - """Convert url to Intersphinx inventory URL.""" - try: - intersphinx.fetch_inventory(SPHINX_MOCK_APP, '', url) - except AttributeError: - raise commands.BadArgument(f"Failed to fetch Intersphinx inventory from URL `{url}`.") - except ConnectionError: - if url.startswith('https'): - raise commands.BadArgument( - f"Cannot establish a connection to `{url}`. Does it support HTTPS?" - ) - raise commands.BadArgument(f"Cannot connect to host with URL `{url}`.") - except ValueError: - raise commands.BadArgument( - f"Failed to read Intersphinx inventory from URL `{url}`. " - "Are you sure that it's a valid inventory file?" - ) - return url - - -class Doc(commands.Cog): - """A set of commands for querying & displaying documentation.""" - - def __init__(self, bot: Bot): - self.base_urls = {} - self.bot = bot - self.inventories = {} - self.renamed_symbols = set() - - self.bot.loop.create_task(self.init_refresh_inventory()) - - async def init_refresh_inventory(self) -> None: - """Refresh documentation inventory on cog initialization.""" - await self.bot.wait_until_guild_available() - await self.refresh_inventory() - - async def update_single( - self, package_name: str, base_url: str, inventory_url: str - ) -> None: - """ - Rebuild the inventory for a single package. - - Where: - * `package_name` is the package name to use, appears in the log - * `base_url` is the root documentation URL for the specified package, used to build - absolute paths that link to specific symbols - * `inventory_url` is the absolute URL to the intersphinx inventory, fetched by running - `intersphinx.fetch_inventory` in an executor on the bot's event loop - """ - self.base_urls[package_name] = base_url - - package = await self._fetch_inventory(inventory_url) - if not package: - return None - - for group, value in package.items(): - for symbol, (package_name, _version, relative_doc_url, _) in value.items(): - absolute_doc_url = base_url + relative_doc_url - - if symbol in self.inventories: - group_name = group.split(":")[1] - symbol_base_url = self.inventories[symbol].split("/", 3)[2] - if ( - group_name in NO_OVERRIDE_GROUPS - or any(package in symbol_base_url for package in NO_OVERRIDE_PACKAGES) - ): - - symbol = f"{group_name}.{symbol}" - # If renamed `symbol` already exists, add library name in front to differentiate between them. - if symbol in self.renamed_symbols: - # Split `package_name` because of packages like Pillow that have spaces in them. - symbol = f"{package_name.split()[0]}.{symbol}" - - self.inventories[symbol] = absolute_doc_url - self.renamed_symbols.add(symbol) - continue - - self.inventories[symbol] = absolute_doc_url - - log.trace(f"Fetched inventory for {package_name}.") - - async def refresh_inventory(self) -> None: - """Refresh internal documentation inventory.""" - log.debug("Refreshing documentation inventory...") - - # Clear the old base URLS and inventories to ensure - # that we start from a fresh local dataset. - # Also, reset the cache used for fetching documentation. - self.base_urls.clear() - self.inventories.clear() - self.renamed_symbols.clear() - async_cache.cache = OrderedDict() - - # Run all coroutines concurrently - since each of them performs a HTTP - # request, this speeds up fetching the inventory data heavily. - coros = [ - self.update_single( - package["package"], package["base_url"], package["inventory_url"] - ) for package in await self.bot.api_client.get('bot/documentation-links') - ] - await asyncio.gather(*coros) - - async def get_symbol_html(self, symbol: str) -> Optional[Tuple[list, str]]: - """ - Given a Python symbol, return its signature and description. - - The first tuple element is the signature of the given symbol as a markup-free string, and - the second tuple element is the description of the given symbol with HTML markup included. - - If the given symbol is a module, returns a tuple `(None, str)` - else if the symbol could not be found, returns `None`. - """ - url = self.inventories.get(symbol) - if url is None: - return None - - async with self.bot.http_session.get(url) as response: - html = await response.text(encoding='utf-8') - - # Find the signature header and parse the relevant parts. - symbol_id = url.split('#')[-1] - soup = BeautifulSoup(html, 'lxml') - symbol_heading = soup.find(id=symbol_id) - search_html = str(soup) - - if symbol_heading is None: - return None - - if symbol_id == f"module-{symbol}": - # Get page content from the module headerlink to the - # first tag that has its class in `SEARCH_END_TAG_ATTRS` - start_tag = symbol_heading.find("a", attrs={"class": "headerlink"}) - if start_tag is None: - return [], "" - - end_tag = start_tag.find_next(self._match_end_tag) - if end_tag is None: - return [], "" - - description_start_index = search_html.find(str(start_tag.parent)) + len(str(start_tag.parent)) - description_end_index = search_html.find(str(end_tag)) - description = search_html[description_start_index:description_end_index] - signatures = None - - else: - signatures = [] - description = str(symbol_heading.find_next_sibling("dd")) - description_pos = search_html.find(description) - # Get text of up to 3 signatures, remove unwanted symbols - for element in [symbol_heading] + symbol_heading.find_next_siblings("dt", limit=2): - signature = UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element.text) - if signature and search_html.find(str(element)) < description_pos: - signatures.append(signature) - - return signatures, description.replace('¶', '') - - @async_cache(arg_offset=1) - async def get_symbol_embed(self, symbol: str) -> Optional[discord.Embed]: - """ - Attempt to scrape and fetch the data for the given `symbol`, and build an embed from its contents. - - If the symbol is known, an Embed with documentation about it is returned. - """ - scraped_html = await self.get_symbol_html(symbol) - if scraped_html is None: - return None - - signatures = scraped_html[0] - permalink = self.inventories[symbol] - description = markdownify(scraped_html[1]) - - # Truncate the description of the embed to the last occurrence - # of a double newline (interpreted as a paragraph) before index 1000. - if len(description) > 1000: - shortened = description[:1000] - description_cutoff = shortened.rfind('\n\n', 100) - if description_cutoff == -1: - # Search the shortened version for cutoff points in decreasing desirability, - # cutoff at 1000 if none are found. - for string in (". ", ", ", ",", " "): - description_cutoff = shortened.rfind(string) - if description_cutoff != -1: - break - else: - description_cutoff = 1000 - description = description[:description_cutoff] - - # If there is an incomplete code block, cut it out - if description.count("```") % 2: - codeblock_start = description.rfind('```py') - description = description[:codeblock_start].rstrip() - description += f"... [read more]({permalink})" - - description = WHITESPACE_AFTER_NEWLINES_RE.sub('', description) - if signatures is None: - # If symbol is a module, don't show signature. - embed_description = description - - elif not signatures: - # It's some "meta-page", for example: - # https://docs.djangoproject.com/en/dev/ref/views/#module-django.views - embed_description = "This appears to be a generic page not tied to a specific symbol." - - else: - embed_description = "".join(f"```py\n{textwrap.shorten(signature, 500)}```" for signature in signatures) - embed_description += f"\n{description}" - - embed = discord.Embed( - title=f'`{symbol}`', - url=permalink, - description=embed_description - ) - # Show all symbols with the same name that were renamed in the footer. - embed.set_footer( - text=", ".join(renamed for renamed in self.renamed_symbols - {symbol} if renamed.endswith(f".{symbol}")) - ) - return embed - - @commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True) - async def docs_group(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None: - """Lookup documentation for Python symbols.""" - await ctx.invoke(self.get_command, symbol) - - @docs_group.command(name='get', aliases=('g',)) - async def get_command(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None: - """ - Return a documentation embed for a given symbol. - - If no symbol is given, return a list of all available inventories. - - Examples: - !docs - !docs aiohttp - !docs aiohttp.ClientSession - !docs get aiohttp.ClientSession - """ - if symbol is None: - inventory_embed = discord.Embed( - title=f"All inventories (`{len(self.base_urls)}` total)", - colour=discord.Colour.blue() - ) - - lines = sorted(f"• [`{name}`]({url})" for name, url in self.base_urls.items()) - if self.base_urls: - await LinePaginator.paginate(lines, ctx, inventory_embed, max_size=400, empty=False) - - else: - inventory_embed.description = "Hmmm, seems like there's nothing here yet." - await ctx.send(embed=inventory_embed) - - else: - # Fetching documentation for a symbol (at least for the first time, since - # caching is used) takes quite some time, so let's send typing to indicate - # that we got the command, but are still working on it. - async with ctx.typing(): - doc_embed = await self.get_symbol_embed(symbol) - - if doc_embed is None: - error_embed = discord.Embed( - description=f"Sorry, I could not find any documentation for `{symbol}`.", - colour=discord.Colour.red() - ) - error_message = await ctx.send(embed=error_embed) - with suppress(NotFound): - await error_message.delete(delay=NOT_FOUND_DELETE_DELAY) - await ctx.message.delete(delay=NOT_FOUND_DELETE_DELAY) - else: - await ctx.send(embed=doc_embed) - - @docs_group.command(name='set', aliases=('s',)) - @with_role(*MODERATION_ROLES) - async def set_command( - self, ctx: commands.Context, package_name: ValidPythonIdentifier, - base_url: ValidURL, inventory_url: InventoryURL - ) -> None: - """ - Adds a new documentation metadata object to the site's database. - - The database will update the object, should an existing item with the specified `package_name` already exist. - - Example: - !docs set \ - python \ - https://docs.python.org/3/ \ - https://docs.python.org/3/objects.inv - """ - body = { - 'package': package_name, - 'base_url': base_url, - 'inventory_url': inventory_url - } - await self.bot.api_client.post('bot/documentation-links', json=body) - - log.info( - f"User @{ctx.author} ({ctx.author.id}) added a new documentation package:\n" - f"Package name: {package_name}\n" - f"Base url: {base_url}\n" - f"Inventory URL: {inventory_url}" - ) - - # Rebuilding the inventory can take some time, so lets send out a - # typing event to show that the Bot is still working. - async with ctx.typing(): - await self.refresh_inventory() - await ctx.send(f"Added package `{package_name}` to database and refreshed inventory.") - - @docs_group.command(name='delete', aliases=('remove', 'rm', 'd')) - @with_role(*MODERATION_ROLES) - async def delete_command(self, ctx: commands.Context, package_name: ValidPythonIdentifier) -> None: - """ - Removes the specified package from the database. - - Examples: - !docs delete aiohttp - """ - await self.bot.api_client.delete(f'bot/documentation-links/{package_name}') - - async with ctx.typing(): - # Rebuild the inventory to ensure that everything - # that was from this package is properly deleted. - await self.refresh_inventory() - await ctx.send(f"Successfully deleted `{package_name}` and refreshed inventory.") - - @docs_group.command(name="refresh", aliases=("rfsh", "r")) - @with_role(*MODERATION_ROLES) - async def refresh_command(self, ctx: commands.Context) -> None: - """Refresh inventories and send differences to channel.""" - old_inventories = set(self.base_urls) - with ctx.typing(): - await self.refresh_inventory() - # Get differences of added and removed inventories - added = ', '.join(inv for inv in self.base_urls if inv not in old_inventories) - if added: - added = f"+ {added}" - - removed = ', '.join(inv for inv in old_inventories if inv not in self.base_urls) - if removed: - removed = f"- {removed}" - - embed = discord.Embed( - title="Inventories refreshed", - description=f"```diff\n{added}\n{removed}```" if added or removed else "" - ) - await ctx.send(embed=embed) - - async def _fetch_inventory(self, inventory_url: str) -> Optional[dict]: - """Get and return inventory from `inventory_url`. If fetching fails, return None.""" - fetch_func = functools.partial(intersphinx.fetch_inventory, SPHINX_MOCK_APP, '', inventory_url) - for retry in range(1, FAILED_REQUEST_RETRY_AMOUNT+1): - try: - package = await self.bot.loop.run_in_executor(None, fetch_func) - except ConnectTimeout: - log.error( - f"Fetching of inventory {inventory_url} timed out," - f" trying again. ({retry}/{FAILED_REQUEST_RETRY_AMOUNT})" - ) - except ProtocolError: - log.error( - f"Connection lost while fetching inventory {inventory_url}," - f" trying again. ({retry}/{FAILED_REQUEST_RETRY_AMOUNT})" - ) - except HTTPError as e: - log.error(f"Fetching of inventory {inventory_url} failed with status code {e.response.status_code}.") - return None - except ConnectionError: - log.error(f"Couldn't establish connection to inventory {inventory_url}.") - return None - else: - return package - log.error(f"Fetching of inventory {inventory_url} failed.") - return None - - @staticmethod - def _match_end_tag(tag: Tag) -> bool: - """Matches `tag` if its class value is in `SEARCH_END_TAG_ATTRS` or the tag is table.""" - for attr in SEARCH_END_TAG_ATTRS: - if attr in tag.get("class", ()): - return True - - return tag.name == "table" - - -def setup(bot: Bot) -> None: - """Load the Doc cog.""" - bot.add_cog(Doc(bot)) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py deleted file mode 100644 index f9d4de638..000000000 --- a/bot/cogs/error_handler.py +++ /dev/null @@ -1,287 +0,0 @@ -import contextlib -import logging -import typing as t - -from discord import Embed -from discord.ext.commands import Cog, Context, errors -from sentry_sdk import push_scope - -from bot.api import ResponseCodeError -from bot.bot import Bot -from bot.constants import Channels, Colours -from bot.converters import TagNameConverter -from bot.utils.checks import InWhitelistCheckFailure - -log = logging.getLogger(__name__) - - -class ErrorHandler(Cog): - """Handles errors emitted from commands.""" - - def __init__(self, bot: Bot): - self.bot = bot - - def _get_error_embed(self, title: str, body: str) -> Embed: - """Return an embed that contains the exception.""" - return Embed( - title=title, - colour=Colours.soft_red, - description=body - ) - - @Cog.listener() - async def on_command_error(self, ctx: Context, e: errors.CommandError) -> None: - """ - Provide generic command error handling. - - Error handling is deferred to any local error handler, if present. This is done by - checking for the presence of a `handled` attribute on the error. - - Error handling emits a single error message in the invoking context `ctx` and a log message, - prioritised as follows: - - 1. If the name fails to match a command: - * If it matches shh+ or unshh+, the channel is silenced or unsilenced respectively. - Otherwise if it matches a tag, the tag is invoked - * If CommandNotFound is raised when invoking the tag (determined by the presence of the - `invoked_from_error_handler` attribute), this error is treated as being unexpected - and therefore sends an error message - * Commands in the verification channel are ignored - 2. UserInputError: see `handle_user_input_error` - 3. CheckFailure: see `handle_check_failure` - 4. CommandOnCooldown: send an error message in the invoking context - 5. ResponseCodeError: see `handle_api_error` - 6. Otherwise, if not a DisabledCommand, handling is deferred to `handle_unexpected_error` - """ - command = ctx.command - - if hasattr(e, "handled"): - 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 await self.try_silence(ctx): - return - if ctx.channel.id != Channels.verification: - # Try to look for a tag with the command's name - await self.try_get_tag(ctx) - return # Exit early to avoid logging. - elif isinstance(e, errors.UserInputError): - await self.handle_user_input_error(ctx, e) - elif isinstance(e, errors.CheckFailure): - await self.handle_check_failure(ctx, e) - elif isinstance(e, errors.CommandOnCooldown): - await ctx.send(e) - elif isinstance(e, errors.CommandInvokeError): - if isinstance(e.original, ResponseCodeError): - await self.handle_api_error(ctx, e.original) - else: - await self.handle_unexpected_error(ctx, e.original) - return # Exit early to avoid logging. - elif not isinstance(e, errors.DisabledCommand): - # ConversionError, MaxConcurrencyReached, ExtensionError - await self.handle_unexpected_error(ctx, e) - return # Exit early to avoid logging. - - log.debug( - f"Command {command} invoked by {ctx.message.author} with error " - f"{e.__class__.__name__}: {e}" - ) - - @staticmethod - def get_help_command(ctx: Context) -> t.Coroutine: - """Return a prepared `help` command invocation coroutine.""" - if ctx.command: - return ctx.send_help(ctx.command) - - return ctx.send_help() - - async def try_silence(self, ctx: Context) -> bool: - """ - Attempt to invoke the silence or unsilence command if invoke with matches a pattern. - - Respecting the checks if: - * invoked with `shh+` silence channel for amount of h's*2 with max of 15. - * invoked with `unshh+` unsilence channel - Return bool depending on success of command. - """ - command = ctx.invoked_with.lower() - silence_command = self.bot.get_command("silence") - ctx.invoked_from_error_handler = True - try: - if not await silence_command.can_run(ctx): - log.debug("Cancelling attempt to invoke silence/unsilence due to failed checks.") - return False - except errors.CommandError: - log.debug("Cancelling attempt to invoke silence/unsilence due to failed checks.") - return False - if command.startswith("shh"): - await ctx.invoke(silence_command, duration=min(command.count("h")*2, 15)) - return True - elif command.startswith("unshh"): - await ctx.invoke(self.bot.get_command("unsilence")) - return True - return False - - async def try_get_tag(self, ctx: Context) -> None: - """ - Attempt to display a tag by interpreting the command name as a tag name. - - The invocation of tags get respects its checks. Any CommandErrors raised will be handled - by `on_command_error`, but the `invoked_from_error_handler` attribute will be added to - the context to prevent infinite recursion in the case of a CommandNotFound exception. - """ - tags_get_command = self.bot.get_command("tags get") - ctx.invoked_from_error_handler = True - - log_msg = "Cancelling attempt to fall back to a tag due to failed checks." - try: - if not await tags_get_command.can_run(ctx): - log.debug(log_msg) - return - except errors.CommandError as tag_error: - log.debug(log_msg) - await self.on_command_error(ctx, tag_error) - return - - try: - tag_name = await TagNameConverter.convert(ctx, ctx.invoked_with) - except errors.BadArgument: - log.debug( - f"{ctx.author} tried to use an invalid command " - f"and the fallback tag failed validation in TagNameConverter." - ) - else: - with contextlib.suppress(ResponseCodeError): - await ctx.invoke(tags_get_command, tag_name=tag_name) - # Return to not raise the exception - return - - async def handle_user_input_error(self, ctx: Context, e: errors.UserInputError) -> None: - """ - Send an error message in `ctx` for UserInputError, sometimes invoking the help command too. - - * MissingRequiredArgument: send an error message with arg name and the help command - * TooManyArguments: send an error message and the help command - * BadArgument: send an error message and the help command - * BadUnionArgument: send an error message including the error produced by the last converter - * 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): - embed = self._get_error_embed("Missing required argument", e.param.name) - await ctx.send(embed=embed) - await prepared_help_command - self.bot.stats.incr("errors.missing_required_argument") - elif isinstance(e, errors.TooManyArguments): - embed = self._get_error_embed("Too many arguments", str(e)) - await ctx.send(embed=embed) - await prepared_help_command - self.bot.stats.incr("errors.too_many_arguments") - elif isinstance(e, errors.BadArgument): - embed = self._get_error_embed("Bad argument", str(e)) - await ctx.send(embed=embed) - await prepared_help_command - self.bot.stats.incr("errors.bad_argument") - elif isinstance(e, errors.BadUnionArgument): - embed = self._get_error_embed("Bad argument", f"{e}\n{e.errors[-1]}") - await ctx.send(embed=embed) - 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) - self.bot.stats.incr("errors.argument_parsing_error") - else: - embed = self._get_error_embed( - "Input error", - "Something about your input seems off. Check the arguments and try again." - ) - await ctx.send(embed=embed) - await prepared_help_command - self.bot.stats.incr("errors.other_user_input_error") - - @staticmethod - async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None: - """ - Send an error message in `ctx` for certain types of CheckFailure. - - The following types are handled: - - * BotMissingPermissions - * BotMissingRole - * BotMissingAnyRole - * NoPrivateMessage - * InWhitelistCheckFailure - """ - bot_missing_errors = ( - errors.BotMissingPermissions, - errors.BotMissingRole, - errors.BotMissingAnyRole - ) - - if isinstance(e, bot_missing_errors): - ctx.bot.stats.incr("errors.bot_permission_error") - await ctx.send( - "Sorry, it looks like I don't have the permissions or roles I need to do that." - ) - elif isinstance(e, (InWhitelistCheckFailure, errors.NoPrivateMessage)): - ctx.bot.stats.incr("errors.wrong_channel_or_dm_error") - await ctx.send(e) - - @staticmethod - async def handle_api_error(ctx: Context, e: ResponseCodeError) -> None: - """Send an error message in `ctx` for ResponseCodeError and log it.""" - if e.status == 404: - await ctx.send("There does not seem to be anything matching your query.") - log.debug(f"API responded with 404 for command {ctx.command}") - ctx.bot.stats.incr("errors.api_error_404") - elif e.status == 400: - content = await e.response.json() - log.debug(f"API responded with 400 for command {ctx.command}: %r.", content) - await ctx.send("According to the API, your request is malformed.") - ctx.bot.stats.incr("errors.api_error_400") - elif 500 <= e.status < 600: - await ctx.send("Sorry, there seems to be an internal issue with the API.") - log.warning(f"API responded with {e.status} for command {ctx.command}") - ctx.bot.stats.incr("errors.api_internal_server_error") - else: - await ctx.send(f"Got an unexpected status code from the API (`{e.status}`).") - log.warning(f"Unexpected API response for command {ctx.command}: {e.status}") - ctx.bot.stats.incr(f"errors.api_error_{e.status}") - - @staticmethod - async def handle_unexpected_error(ctx: Context, e: errors.CommandError) -> None: - """Send a generic error message in `ctx` and log the exception as an error with exc_info.""" - await ctx.send( - f"Sorry, an unexpected error occurred. Please let us know!\n\n" - f"```{e.__class__.__name__}: {e}```" - ) - - ctx.bot.stats.incr("errors.unexpected") - - with push_scope() as scope: - scope.user = { - "id": ctx.author.id, - "username": str(ctx.author) - } - - scope.set_tag("command", ctx.command.qualified_name) - scope.set_tag("message_id", ctx.message.id) - scope.set_tag("channel_id", ctx.channel.id) - - scope.set_extra("full_message", ctx.message.content) - - if ctx.guild is not None: - scope.set_extra( - "jump_to", - f"https://discordapp.com/channels/{ctx.guild.id}/{ctx.channel.id}/{ctx.message.id}" - ) - - log.error(f"Error executing command invoked by {ctx.message.author}: {ctx.message.content}", exc_info=e) - - -def setup(bot: Bot) -> None: - """Load the ErrorHandler cog.""" - bot.add_cog(ErrorHandler(bot)) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py deleted file mode 100644 index eb8bfb1cf..000000000 --- a/bot/cogs/eval.py +++ /dev/null @@ -1,202 +0,0 @@ -import contextlib -import inspect -import logging -import pprint -import re -import textwrap -import traceback -from io import StringIO -from typing import Any, Optional, Tuple - -import discord -from discord.ext.commands import Cog, Context, group - -from bot.bot import Bot -from bot.constants import Roles -from bot.decorators import with_role -from bot.interpreter import Interpreter - -log = logging.getLogger(__name__) - - -class CodeEval(Cog): - """Owner and admin feature that evaluates code and returns the result to the channel.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.env = {} - self.ln = 0 - self.stdout = StringIO() - - self.interpreter = Interpreter(bot) - - def _format(self, inp: str, out: Any) -> Tuple[str, Optional[discord.Embed]]: - """Format the eval output into a string & attempt to format it into an Embed.""" - self._ = out - - res = "" - - # Erase temp input we made - if inp.startswith("_ = "): - inp = inp[4:] - - # Get all non-empty lines - lines = [line for line in inp.split("\n") if line.strip()] - if len(lines) != 1: - lines += [""] - - # Create the input dialog - for i, line in enumerate(lines): - if i == 0: - # Start dialog - start = f"In [{self.ln}]: " - - else: - # Indent the 3 dots correctly; - # Normally, it's something like - # In [X]: - # ...: - # - # But if it's - # In [XX]: - # ...: - # - # You can see it doesn't look right. - # This code simply indents the dots - # far enough to align them. - # we first `str()` the line number - # then we get the length - # and use `str.rjust()` - # to indent it. - start = "...: ".rjust(len(str(self.ln)) + 7) - - if i == len(lines) - 2: - if line.startswith("return"): - line = line[6:].strip() - - # Combine everything - res += (start + line + "\n") - - self.stdout.seek(0) - text = self.stdout.read() - self.stdout.close() - self.stdout = StringIO() - - if text: - res += (text + "\n") - - if out is None: - # No output, return the input statement - return (res, None) - - res += f"Out[{self.ln}]: " - - if isinstance(out, discord.Embed): - # We made an embed? Send that as embed - res += "" - res = (res, out) - - else: - if (isinstance(out, str) and out.startswith("Traceback (most recent call last):\n")): - # Leave out the traceback message - out = "\n" + "\n".join(out.split("\n")[1:]) - - if isinstance(out, str): - pretty = out - else: - pretty = pprint.pformat(out, compact=True, width=60) - - if pretty != str(out): - # We're using the pretty version, start on the next line - res += "\n" - - if pretty.count("\n") > 20: - # Text too long, shorten - li = pretty.split("\n") - - pretty = ("\n".join(li[:3]) # First 3 lines - + "\n ...\n" # Ellipsis to indicate removed lines - + "\n".join(li[-3:])) # last 3 lines - - # Add the output - res += pretty - res = (res, None) - - return res # Return (text, embed) - - async def _eval(self, ctx: Context, code: str) -> Optional[discord.Message]: - """Eval the input code string & send an embed to the invoking context.""" - self.ln += 1 - - if code.startswith("exit"): - self.ln = 0 - self.env = {} - return await ctx.send("```Reset history!```") - - env = { - "message": ctx.message, - "author": ctx.message.author, - "channel": ctx.channel, - "guild": ctx.guild, - "ctx": ctx, - "self": self, - "bot": self.bot, - "inspect": inspect, - "discord": discord, - "contextlib": contextlib - } - - self.env.update(env) - - # Ignore this code, it works - code_ = """ -async def func(): # (None,) -> Any - try: - with contextlib.redirect_stdout(self.stdout): -{0} - if '_' in locals(): - if inspect.isawaitable(_): - _ = await _ - return _ - finally: - self.env.update(locals()) -""".format(textwrap.indent(code, ' ')) - - try: - exec(code_, self.env) # noqa: B102,S102 - func = self.env['func'] - res = await func() - - except Exception: - res = traceback.format_exc() - - out, embed = self._format(code, res) - await ctx.send(f"```py\n{out}```", embed=embed) - - @group(name='internal', aliases=('int',)) - @with_role(Roles.owners, Roles.admins) - async def internal_group(self, ctx: Context) -> None: - """Internal commands. Top secret!""" - if not ctx.invoked_subcommand: - await ctx.send_help(ctx.command) - - @internal_group.command(name='eval', aliases=('e',)) - @with_role(Roles.admins, Roles.owners) - async def eval(self, ctx: Context, *, code: str) -> None: - """Run eval in a REPL-like format.""" - code = code.strip("`") - if re.match('py(thon)?\n', code): - code = "\n".join(code.split("\n")[1:]) - - if not re.search( # Check if it's an expression - r"^(return|import|for|while|def|class|" - r"from|exit|[a-zA-Z0-9]+\s*=)", code, re.M) and len( - code.split("\n")) == 1: - code = "_ = " + code - - await self._eval(ctx, code) - - -def setup(bot: Bot) -> None: - """Load the CodeEval cog.""" - bot.add_cog(CodeEval(bot)) diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py deleted file mode 100644 index 365f198ff..000000000 --- a/bot/cogs/extensions.py +++ /dev/null @@ -1,236 +0,0 @@ -import functools -import logging -import typing as t -from enum import Enum -from pkgutil import iter_modules - -from discord import Colour, Embed -from discord.ext import commands -from discord.ext.commands import Context, group - -from bot.bot import Bot -from bot.constants import Emojis, MODERATION_ROLES, Roles, URLs -from bot.pagination import LinePaginator -from bot.utils.checks import with_role_check - -log = logging.getLogger(__name__) - -UNLOAD_BLACKLIST = {"bot.cogs.extensions", "bot.cogs.modlog"} -EXTENSIONS = frozenset( - ext.name - for ext in iter_modules(("bot/cogs",), "bot.cogs.") - if ext.name[-1] != "_" -) - - -class Action(Enum): - """Represents an action to perform on an extension.""" - - # Need to be partial otherwise they are considered to be function definitions. - LOAD = functools.partial(Bot.load_extension) - UNLOAD = functools.partial(Bot.unload_extension) - RELOAD = functools.partial(Bot.reload_extension) - - -class Extension(commands.Converter): - """ - Fully qualify the name of an extension and ensure it exists. - - The * and ** values bypass this when used with the reload command. - """ - - async def convert(self, ctx: Context, argument: str) -> str: - """Fully qualify the name of an extension and ensure it exists.""" - # Special values to reload all extensions - if argument == "*" or argument == "**": - return argument - - argument = argument.lower() - - if "." not in argument: - argument = f"bot.cogs.{argument}" - - if argument in EXTENSIONS: - return argument - else: - raise commands.BadArgument(f":x: Could not find the extension `{argument}`.") - - -class Extensions(commands.Cog): - """Extension management commands.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @group(name="extensions", aliases=("ext", "exts", "c", "cogs"), invoke_without_command=True) - async def extensions_group(self, ctx: Context) -> None: - """Load, unload, reload, and list loaded extensions.""" - await ctx.send_help(ctx.command) - - @extensions_group.command(name="load", aliases=("l",)) - async def load_command(self, ctx: Context, *extensions: Extension) -> None: - r""" - Load extensions given their fully qualified or unqualified names. - - If '\*' or '\*\*' is given as the name, all unloaded extensions will be loaded. - """ # noqa: W605 - if not extensions: - await ctx.send_help(ctx.command) - return - - if "*" in extensions or "**" in extensions: - extensions = set(EXTENSIONS) - set(self.bot.extensions.keys()) - - msg = self.batch_manage(Action.LOAD, *extensions) - await ctx.send(msg) - - @extensions_group.command(name="unload", aliases=("ul",)) - async def unload_command(self, ctx: Context, *extensions: Extension) -> None: - r""" - Unload currently loaded extensions given their fully qualified or unqualified names. - - If '\*' or '\*\*' is given as the name, all loaded extensions will be unloaded. - """ # noqa: W605 - if not extensions: - await ctx.send_help(ctx.command) - return - - blacklisted = "\n".join(UNLOAD_BLACKLIST & set(extensions)) - - if blacklisted: - msg = f":x: The following extension(s) may not be unloaded:```{blacklisted}```" - else: - if "*" in extensions or "**" in extensions: - extensions = set(self.bot.extensions.keys()) - UNLOAD_BLACKLIST - - msg = self.batch_manage(Action.UNLOAD, *extensions) - - await ctx.send(msg) - - @extensions_group.command(name="reload", aliases=("r",)) - async def reload_command(self, ctx: Context, *extensions: Extension) -> None: - r""" - Reload extensions given their fully qualified or unqualified names. - - If an extension fails to be reloaded, it will be rolled-back to the prior working state. - - If '\*' is given as the name, all currently loaded extensions will be reloaded. - If '\*\*' is given as the name, all extensions, including unloaded ones, will be reloaded. - """ # noqa: W605 - if not extensions: - await ctx.send_help(ctx.command) - return - - if "**" in extensions: - extensions = EXTENSIONS - elif "*" in extensions: - extensions = set(self.bot.extensions.keys()) | set(extensions) - extensions.remove("*") - - msg = self.batch_manage(Action.RELOAD, *extensions) - - await ctx.send(msg) - - @extensions_group.command(name="list", aliases=("all",)) - async def list_command(self, ctx: Context) -> None: - """ - Get a list of all extensions, including their loaded status. - - Grey indicates that the extension is unloaded. - Green indicates that the extension is currently loaded. - """ - embed = Embed() - lines = [] - - embed.colour = Colour.blurple() - embed.set_author( - name="Extensions List", - url=URLs.github_bot_repo, - icon_url=URLs.bot_avatar - ) - - for ext in sorted(list(EXTENSIONS)): - if ext in self.bot.extensions: - status = Emojis.status_online - else: - status = Emojis.status_offline - - ext = ext.rsplit(".", 1)[1] - lines.append(f"{status} {ext}") - - log.debug(f"{ctx.author} requested a list of all cogs. Returning a paginated list.") - await LinePaginator.paginate(lines, ctx, embed, max_size=300, empty=False) - - def batch_manage(self, action: Action, *extensions: str) -> str: - """ - Apply an action to multiple extensions and return a message with the results. - - If only one extension is given, it is deferred to `manage()`. - """ - if len(extensions) == 1: - msg, _ = self.manage(action, extensions[0]) - return msg - - verb = action.name.lower() - failures = {} - - for extension in extensions: - _, error = self.manage(action, extension) - if error: - failures[extension] = error - - emoji = ":x:" if failures else ":ok_hand:" - msg = f"{emoji} {len(extensions) - len(failures)} / {len(extensions)} extensions {verb}ed." - - if failures: - failures = "\n".join(f"{ext}\n {err}" for ext, err in failures.items()) - msg += f"\nFailures:```{failures}```" - - log.debug(f"Batch {verb}ed extensions.") - - return msg - - def manage(self, action: Action, ext: str) -> t.Tuple[str, t.Optional[str]]: - """Apply an action to an extension and return the status message and any error message.""" - verb = action.name.lower() - error_msg = None - - try: - action.value(self.bot, ext) - except (commands.ExtensionAlreadyLoaded, commands.ExtensionNotLoaded): - if action is Action.RELOAD: - # When reloading, just load the extension if it was not loaded. - return self.manage(Action.LOAD, ext) - - msg = f":x: Extension `{ext}` is already {verb}ed." - log.debug(msg[4:]) - except Exception as e: - if hasattr(e, "original"): - e = e.original - - log.exception(f"Extension '{ext}' failed to {verb}.") - - error_msg = f"{e.__class__.__name__}: {e}" - msg = f":x: Failed to {verb} extension `{ext}`:\n```{error_msg}```" - else: - msg = f":ok_hand: Extension successfully {verb}ed: `{ext}`." - log.debug(msg[10:]) - - return msg, error_msg - - # This cannot be static (must have a __func__ attribute). - def cog_check(self, ctx: Context) -> bool: - """Only allow moderators and core developers to invoke the commands in this cog.""" - return with_role_check(ctx, *MODERATION_ROLES, Roles.core_developers) - - # This cannot be static (must have a __func__ attribute). - async def cog_command_error(self, ctx: Context, error: Exception) -> None: - """Handle BadArgument errors locally to prevent the help command from showing.""" - if isinstance(error, commands.BadArgument): - await ctx.send(str(error)) - error.handled = True - - -def setup(bot: Bot) -> None: - """Load the Extensions cog.""" - bot.add_cog(Extensions(bot)) diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py deleted file mode 100644 index c15adc461..000000000 --- a/bot/cogs/filter_lists.py +++ /dev/null @@ -1,273 +0,0 @@ -import logging -from typing import Optional - -from discord import Colour, Embed -from discord.ext.commands import BadArgument, Cog, Context, IDConverter, group - -from bot import constants -from bot.api import ResponseCodeError -from bot.bot import Bot -from bot.converters import ValidDiscordServerInvite, ValidFilterListType -from bot.pagination import LinePaginator -from bot.utils.checks import with_role_check - -log = logging.getLogger(__name__) - - -class FilterLists(Cog): - """Commands for blacklisting and whitelisting things.""" - - methods_with_filterlist_types = [ - "allow_add", - "allow_delete", - "allow_get", - "deny_add", - "deny_delete", - "deny_get", - ] - - def __init__(self, bot: Bot) -> None: - self.bot = bot - self.bot.loop.create_task(self._amend_docstrings()) - - async def _amend_docstrings(self) -> None: - """Add the valid FilterList types to the docstrings, so they'll appear in !help invocations.""" - await self.bot.wait_until_guild_available() - - # Add valid filterlist types to the docstrings - valid_types = await ValidFilterListType.get_valid_types(self.bot) - valid_types = [f"`{type_.lower()}`" for type_ in valid_types] - - for method_name in self.methods_with_filterlist_types: - command = getattr(self, method_name) - command.help = ( - f"{command.help}\n\nValid **list_type** values are {', '.join(valid_types)}." - ) - - async def _add_data( - self, - ctx: Context, - allowed: bool, - list_type: ValidFilterListType, - content: str, - comment: Optional[str] = None, - ) -> None: - """Add an item to a filterlist.""" - allow_type = "whitelist" if allowed else "blacklist" - - # If this is a server invite, we gotta validate it. - if list_type == "GUILD_INVITE": - guild_data = await self._validate_guild_invite(ctx, content) - content = guild_data.get("id") - - # Unless the user has specified another comment, let's - # use the server name as the comment so that the list - # of guild IDs will be more easily readable when we - # display it. - if not comment: - comment = guild_data.get("name") - - # If it's a file format, let's make sure it has a leading dot. - elif list_type == "FILE_FORMAT" and not content.startswith("."): - content = f".{content}" - - # Try to add the item to the database - log.trace(f"Trying to add the {content} item to the {list_type} {allow_type}") - payload = { - "allowed": allowed, - "type": list_type, - "content": content, - "comment": comment, - } - - try: - item = await self.bot.api_client.post( - "bot/filter-lists", - json=payload - ) - except ResponseCodeError as e: - if e.status == 400: - await ctx.message.add_reaction("❌") - log.debug( - f"{ctx.author} tried to add data to a {allow_type}, but the API returned 400, " - "probably because the request violated the UniqueConstraint." - ) - raise BadArgument( - f"Unable to add the item to the {allow_type}. " - "The item probably already exists. Keep in mind that a " - "blacklist and a whitelist for the same item cannot co-exist, " - "and we do not permit any duplicates." - ) - raise - - # Insert the item into the cache - self.bot.insert_item_into_filter_list_cache(item) - await ctx.message.add_reaction("✅") - - async def _delete_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType, content: str) -> None: - """Remove an item from a filterlist.""" - allow_type = "whitelist" if allowed else "blacklist" - - # If this is a server invite, we need to convert it. - if list_type == "GUILD_INVITE" and not IDConverter()._get_id_match(content): - guild_data = await self._validate_guild_invite(ctx, content) - content = guild_data.get("id") - - # If it's a file format, let's make sure it has a leading dot. - elif list_type == "FILE_FORMAT" and not content.startswith("."): - content = f".{content}" - - # Find the content and delete it. - log.trace(f"Trying to delete the {content} item from the {list_type} {allow_type}") - item = self.bot.filter_list_cache[f"{list_type}.{allowed}"].get(content) - - if item is not None: - try: - await self.bot.api_client.delete( - f"bot/filter-lists/{item['id']}" - ) - del self.bot.filter_list_cache[f"{list_type}.{allowed}"][content] - await ctx.message.add_reaction("✅") - except ResponseCodeError as e: - log.debug( - f"{ctx.author} tried to delete an item with the id {item['id']}, but " - f"the API raised an unexpected error: {e}" - ) - await ctx.message.add_reaction("❌") - else: - await ctx.message.add_reaction("❌") - - async def _list_all_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType) -> None: - """Paginate and display all items in a filterlist.""" - allow_type = "whitelist" if allowed else "blacklist" - result = self.bot.filter_list_cache[f"{list_type}.{allowed}"] - - # Build a list of lines we want to show in the paginator - lines = [] - for content, metadata in result.items(): - line = f"• `{content}`" - - if comment := metadata.get("comment"): - line += f" - {comment}" - - lines.append(line) - lines = sorted(lines) - - # Build the embed - list_type_plural = list_type.lower().replace("_", " ").title() + "s" - embed = Embed( - title=f"{allow_type.title()}ed {list_type_plural} ({len(result)} total)", - colour=Colour.blue() - ) - log.trace(f"Trying to list {len(result)} items from the {list_type.lower()} {allow_type}") - - if result: - await LinePaginator.paginate(lines, ctx, embed, max_lines=15, empty=False) - else: - embed.description = "Hmmm, seems like there's nothing here yet." - await ctx.send(embed=embed) - await ctx.message.add_reaction("❌") - - async def _sync_data(self, ctx: Context) -> None: - """Syncs the filterlists with the API.""" - try: - log.trace("Attempting to sync FilterList cache with data from the API.") - await self.bot.cache_filter_list_data() - await ctx.message.add_reaction("✅") - except ResponseCodeError as e: - log.debug( - f"{ctx.author} tried to sync FilterList cache data but " - f"the API raised an unexpected error: {e}" - ) - await ctx.message.add_reaction("❌") - - @staticmethod - async def _validate_guild_invite(ctx: Context, invite: str) -> dict: - """ - Validates a guild invite, and returns the guild info as a dict. - - Will raise a BadArgument if the guild invite is invalid. - """ - log.trace(f"Attempting to validate whether or not {invite} is a guild invite.") - validator = ValidDiscordServerInvite() - guild_data = await validator.convert(ctx, invite) - - # If we make it this far without raising a BadArgument, the invite is - # valid. Let's return a dict of guild information. - log.trace(f"{invite} validated as server invite. Converting to ID.") - return guild_data - - @group(aliases=("allowlist", "allow", "al", "wl")) - async def whitelist(self, ctx: Context) -> None: - """Group for whitelisting commands.""" - if not ctx.invoked_subcommand: - await ctx.send_help(ctx.command) - - @group(aliases=("denylist", "deny", "bl", "dl")) - async def blacklist(self, ctx: Context) -> None: - """Group for blacklisting commands.""" - if not ctx.invoked_subcommand: - await ctx.send_help(ctx.command) - - @whitelist.command(name="add", aliases=("a", "set")) - async def allow_add( - self, - ctx: Context, - list_type: ValidFilterListType, - content: str, - *, - comment: Optional[str] = None, - ) -> None: - """Add an item to the specified allowlist.""" - await self._add_data(ctx, True, list_type, content, comment) - - @blacklist.command(name="add", aliases=("a", "set")) - async def deny_add( - self, - ctx: Context, - list_type: ValidFilterListType, - content: str, - *, - comment: Optional[str] = None, - ) -> None: - """Add an item to the specified denylist.""" - await self._add_data(ctx, False, list_type, content, comment) - - @whitelist.command(name="remove", aliases=("delete", "rm",)) - async def allow_delete(self, ctx: Context, list_type: ValidFilterListType, content: str) -> None: - """Remove an item from the specified allowlist.""" - await self._delete_data(ctx, True, list_type, content) - - @blacklist.command(name="remove", aliases=("delete", "rm",)) - async def deny_delete(self, ctx: Context, list_type: ValidFilterListType, content: str) -> None: - """Remove an item from the specified denylist.""" - await self._delete_data(ctx, False, list_type, content) - - @whitelist.command(name="get", aliases=("list", "ls", "fetch", "show")) - async def allow_get(self, ctx: Context, list_type: ValidFilterListType) -> None: - """Get the contents of a specified allowlist.""" - await self._list_all_data(ctx, True, list_type) - - @blacklist.command(name="get", aliases=("list", "ls", "fetch", "show")) - async def deny_get(self, ctx: Context, list_type: ValidFilterListType) -> None: - """Get the contents of a specified denylist.""" - await self._list_all_data(ctx, False, list_type) - - @whitelist.command(name="sync", aliases=("s",)) - async def allow_sync(self, ctx: Context) -> None: - """Syncs both allowlists and denylists with the API.""" - await self._sync_data(ctx) - - @blacklist.command(name="sync", aliases=("s",)) - async def deny_sync(self, ctx: Context) -> None: - """Syncs both allowlists and denylists with the API.""" - await self._sync_data(ctx) - - def cog_check(self, ctx: Context) -> bool: - """Only allow moderators to invoke the commands in this cog.""" - return with_role_check(ctx, *constants.MODERATION_ROLES) - - -def setup(bot: Bot) -> None: - """Load the FilterLists cog.""" - bot.add_cog(FilterLists(bot)) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py deleted file mode 100644 index 93cc1c655..000000000 --- a/bot/cogs/filtering.py +++ /dev/null @@ -1,575 +0,0 @@ -import asyncio -import logging -import re -from datetime import datetime, timedelta -from typing import List, Mapping, Optional, Tuple, Union - -import dateutil -import discord.errors -from dateutil.relativedelta import relativedelta -from discord import Colour, HTTPException, Member, Message, NotFound, TextChannel -from discord.ext.commands import Cog -from discord.utils import escape_markdown - -from bot.bot import Bot -from bot.cogs.moderation import ModLog -from bot.constants import ( - Channels, Colours, - Filter, Icons, URLs -) -from bot.utils.redis_cache import RedisCache -from bot.utils.regex import INVITE_RE -from bot.utils.scheduling import Scheduler - -log = logging.getLogger(__name__) - -# Regular expressions -SPOILER_RE = re.compile(r"(\|\|.+?\|\|)", re.DOTALL) -URL_RE = re.compile(r"(https?://[^\s]+)", flags=re.IGNORECASE) -ZALGO_RE = re.compile(r"[\u0300-\u036F\u0489]") - -# Other constants. -DAYS_BETWEEN_ALERTS = 3 -OFFENSIVE_MSG_DELETE_TIME = timedelta(days=Filter.offensive_msg_delete_days) - - -class Filtering(Cog): - """Filtering out invites, blacklisting domains, and warning us of certain regular expressions.""" - - # Redis cache mapping a user ID to the last timestamp a bad nickname alert was sent - name_alerts = RedisCache() - - def __init__(self, bot: Bot): - self.bot = bot - self.scheduler = Scheduler(self.__class__.__name__) - self.name_lock = asyncio.Lock() - - staff_mistake_str = "If you believe this was a mistake, please let staff know!" - self.filters = { - "filter_zalgo": { - "enabled": Filter.filter_zalgo, - "function": self._has_zalgo, - "type": "filter", - "content_only": True, - "user_notification": Filter.notify_user_zalgo, - "notification_msg": ( - "Your post has been removed for abusing Unicode character rendering (aka Zalgo text). " - f"{staff_mistake_str}" - ), - "schedule_deletion": False - }, - "filter_invites": { - "enabled": Filter.filter_invites, - "function": self._has_invites, - "type": "filter", - "content_only": True, - "user_notification": Filter.notify_user_invites, - "notification_msg": ( - f"Per Rule 6, your invite link has been removed. {staff_mistake_str}\n\n" - r"Our server rules can be found here: " - ), - "schedule_deletion": False - }, - "filter_domains": { - "enabled": Filter.filter_domains, - "function": self._has_urls, - "type": "filter", - "content_only": True, - "user_notification": Filter.notify_user_domains, - "notification_msg": ( - f"Your URL has been removed because it matched a blacklisted domain. {staff_mistake_str}" - ), - "schedule_deletion": False - }, - "watch_regex": { - "enabled": Filter.watch_regex, - "function": self._has_watch_regex_match, - "type": "watchlist", - "content_only": True, - "schedule_deletion": True - }, - "watch_rich_embeds": { - "enabled": Filter.watch_rich_embeds, - "function": self._has_rich_embed, - "type": "watchlist", - "content_only": False, - "schedule_deletion": False - } - } - - self.bot.loop.create_task(self.reschedule_offensive_msg_deletion()) - - def cog_unload(self) -> None: - """Cancel scheduled tasks.""" - self.scheduler.cancel_all() - - def _get_filterlist_items(self, list_type: str, *, allowed: bool) -> list: - """Fetch items from the filter_list_cache.""" - return self.bot.filter_list_cache[f"{list_type.upper()}.{allowed}"].keys() - - @staticmethod - def _expand_spoilers(text: str) -> str: - """Return a string containing all interpretations of a spoilered message.""" - split_text = SPOILER_RE.split(text) - return ''.join( - split_text[0::2] + split_text[1::2] + split_text - ) - - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - @Cog.listener() - async def on_message(self, msg: Message) -> None: - """Invoke message filter for new messages.""" - await self._filter_message(msg) - - # Ignore webhook messages. - if msg.webhook_id is None: - await self.check_bad_words_in_name(msg.author) - - @Cog.listener() - async def on_message_edit(self, before: Message, after: Message) -> None: - """ - Invoke message filter for message edits. - - If there have been multiple edits, calculate the time delta from the previous edit. - """ - if not before.edited_at: - delta = relativedelta(after.edited_at, before.created_at).microseconds - else: - delta = relativedelta(after.edited_at, before.edited_at).microseconds - await self._filter_message(after, delta) - - def get_name_matches(self, name: str) -> List[re.Match]: - """Check bad words from passed string (name). Return list of matches.""" - matches = [] - watchlist_patterns = self._get_filterlist_items('filter_token', allowed=False) - for pattern in watchlist_patterns: - if match := re.search(pattern, name, flags=re.IGNORECASE): - matches.append(match) - return matches - - async def check_send_alert(self, member: Member) -> bool: - """When there is less than 3 days after last alert, return `False`, otherwise `True`.""" - if last_alert := await self.name_alerts.get(member.id): - last_alert = datetime.utcfromtimestamp(last_alert) - if datetime.utcnow() - timedelta(days=DAYS_BETWEEN_ALERTS) < last_alert: - log.trace(f"Last alert was too recent for {member}'s nickname.") - return False - - return True - - async def check_bad_words_in_name(self, member: Member) -> None: - """Send a mod alert every 3 days if a username still matches a watchlist pattern.""" - # Use lock to avoid race conditions - async with self.name_lock: - # Check whether the users display name contains any words in our blacklist - matches = self.get_name_matches(member.display_name) - - if not matches or not await self.check_send_alert(member): - return - - log.info(f"Sending bad nickname alert for '{member.display_name}' ({member.id}).") - - log_string = ( - f"**User:** {member.mention} (`{member.id}`)\n" - f"**Display Name:** {member.display_name}\n" - f"**Bad Matches:** {', '.join(match.group() for match in matches)}" - ) - - await self.mod_log.send_log_message( - icon_url=Icons.token_removed, - colour=Colours.soft_red, - title="Username filtering alert", - text=log_string, - channel_id=Channels.mod_alerts, - thumbnail=member.avatar_url - ) - - # Update time when alert sent - await self.name_alerts.set(member.id, datetime.utcnow().timestamp()) - - async def filter_eval(self, result: str, msg: Message) -> bool: - """ - Filter the result of an !eval to see if it violates any of our rules, and then respond accordingly. - - Also requires the original message, to check whether to filter and for mod logs. - Returns whether a filter was triggered or not. - """ - filter_triggered = False - # Should we filter this message? - if self._check_filter(msg): - for filter_name, _filter in self.filters.items(): - # Is this specific filter enabled in the config? - # We also do not need to worry about filters that take the full message, - # since all we have is an arbitrary string. - if _filter["enabled"] and _filter["content_only"]: - match = await _filter["function"](result) - - if match: - # If this is a filter (not a watchlist), we set the variable so we know - # that it has been triggered - if _filter["type"] == "filter": - filter_triggered = True - - # We do not have to check against DM channels since !eval cannot be used there. - channel_str = f"in {msg.channel.mention}" - - message_content, additional_embeds, additional_embeds_msg = self._add_stats( - filter_name, match, result - ) - - message = ( - f"The {filter_name} {_filter['type']} was triggered " - f"by **{msg.author}** " - f"(`{msg.author.id}`) {channel_str} using !eval with " - f"[the following message]({msg.jump_url}):\n\n" - f"{message_content}" - ) - - log.debug(message) - - # Send pretty mod log embed to mod-alerts - await self.mod_log.send_log_message( - icon_url=Icons.filtering, - colour=Colour(Colours.soft_red), - title=f"{_filter['type'].title()} triggered!", - text=message, - thumbnail=msg.author.avatar_url_as(static_format="png"), - channel_id=Channels.mod_alerts, - ping_everyone=Filter.ping_everyone, - additional_embeds=additional_embeds, - additional_embeds_msg=additional_embeds_msg - ) - - break # We don't want multiple filters to trigger - - return filter_triggered - - async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> None: - """Filter the input message to see if it violates any of our rules, and then respond accordingly.""" - # Should we filter this message? - if self._check_filter(msg): - for filter_name, _filter in self.filters.items(): - # Is this specific filter enabled in the config? - if _filter["enabled"]: - # Double trigger check for the embeds filter - if filter_name == "watch_rich_embeds": - # If the edit delta is less than 0.001 seconds, then we're probably dealing - # with a double filter trigger. - if delta is not None and delta < 100: - continue - - # Does the filter only need the message content or the full message? - if _filter["content_only"]: - match = await _filter["function"](msg.content) - else: - match = await _filter["function"](msg) - - if match: - is_private = msg.channel.type is discord.ChannelType.private - - # If this is a filter (not a watchlist) and not in a DM, delete the message. - if _filter["type"] == "filter" and not is_private: - try: - # Embeds (can?) trigger both the `on_message` and `on_message_edit` - # event handlers, triggering filtering twice for the same message. - # - # If `on_message`-triggered filtering already deleted the message - # then `on_message_edit`-triggered filtering will raise exception - # since the message no longer exists. - # - # In addition, to avoid sending two notifications to the user, the - # logs, and mod_alert, we return if the message no longer exists. - await msg.delete() - except discord.errors.NotFound: - return - - # Notify the user if the filter specifies - if _filter["user_notification"]: - await self.notify_member(msg.author, _filter["notification_msg"], msg.channel) - - # If the message is classed as offensive, we store it in the site db and - # it will be deleted it after one week. - if _filter["schedule_deletion"] and not is_private: - delete_date = (msg.created_at + OFFENSIVE_MSG_DELETE_TIME).isoformat() - data = { - 'id': msg.id, - 'channel_id': msg.channel.id, - 'delete_date': delete_date - } - - await self.bot.api_client.post('bot/offensive-messages', json=data) - self.schedule_msg_delete(data) - log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}") - - if is_private: - channel_str = "via DM" - else: - channel_str = f"in {msg.channel.mention}" - - message_content, additional_embeds, additional_embeds_msg = self._add_stats( - filter_name, match, msg.content - ) - - message = ( - f"The {filter_name} {_filter['type']} was triggered " - f"by **{msg.author}** " - f"(`{msg.author.id}`) {channel_str} with [the " - f"following message]({msg.jump_url}):\n\n" - f"{message_content}" - ) - - log.debug(message) - - # Send pretty mod log embed to mod-alerts - await self.mod_log.send_log_message( - icon_url=Icons.filtering, - colour=Colour(Colours.soft_red), - title=f"{_filter['type'].title()} triggered!", - text=message, - thumbnail=msg.author.avatar_url_as(static_format="png"), - channel_id=Channels.mod_alerts, - ping_everyone=Filter.ping_everyone if not is_private else False, - additional_embeds=additional_embeds, - additional_embeds_msg=additional_embeds_msg - ) - - break # We don't want multiple filters to trigger - - def _add_stats(self, name: str, match: Union[re.Match, dict, bool, List[discord.Embed]], content: str) -> Tuple[ - str, Optional[List[discord.Embed]], Optional[str] - ]: - """Adds relevant statistical information to the relevant filter and increments the bot's stats.""" - # Word and match stats for watch_regex - if name == "watch_regex": - surroundings = match.string[max(match.start() - 10, 0): match.end() + 10] - message_content = ( - f"**Match:** '{match[0]}'\n" - f"**Location:** '...{escape_markdown(surroundings)}...'\n" - f"\n**Original Message:**\n{escape_markdown(content)}" - ) - else: # Use original content - message_content = content - - additional_embeds = None - additional_embeds_msg = None - - self.bot.stats.incr(f"filters.{name}") - - # The function returns True for invalid invites. - # They have no data so additional embeds can't be created for them. - if name == "filter_invites" and match is not True: - additional_embeds = [] - for _, data in match.items(): - embed = discord.Embed(description=( - f"**Members:**\n{data['members']}\n" - f"**Active:**\n{data['active']}" - )) - embed.set_author(name=data["name"]) - embed.set_thumbnail(url=data["icon"]) - embed.set_footer(text=f"Guild ID: {data['id']}") - additional_embeds.append(embed) - additional_embeds_msg = "For the following guild(s):" - - elif name == "watch_rich_embeds": - additional_embeds = match - additional_embeds_msg = "With the following embed(s):" - - return message_content, additional_embeds, additional_embeds_msg - - @staticmethod - def _check_filter(msg: Message) -> bool: - """Check whitelists to see if we should filter this message.""" - role_whitelisted = False - - if type(msg.author) is Member: # Only Member has roles, not User. - for role in msg.author.roles: - if role.id in Filter.role_whitelist: - role_whitelisted = True - - return ( - msg.channel.id not in Filter.channel_whitelist # Channel not in whitelist - and not role_whitelisted # Role not in whitelist - and not msg.author.bot # Author not a bot - ) - - async def _has_watch_regex_match(self, text: str) -> Union[bool, re.Match]: - """ - Return True if `text` matches any regex from `word_watchlist` or `token_watchlist` configs. - - `word_watchlist`'s patterns are placed between word boundaries while `token_watchlist` is - matched as-is. Spoilers are expanded, if any, and URLs are ignored. - """ - if SPOILER_RE.search(text): - text = self._expand_spoilers(text) - - # Make sure it's not a URL - if URL_RE.search(text): - return False - - watchlist_patterns = self._get_filterlist_items('filter_token', allowed=False) - for pattern in watchlist_patterns: - match = re.search(pattern, text, flags=re.IGNORECASE) - if match: - return match - - async def _has_urls(self, text: str) -> bool: - """Returns True if the text contains one of the blacklisted URLs from the config file.""" - if not URL_RE.search(text): - return False - - text = text.lower() - domain_blacklist = self._get_filterlist_items("domain_name", allowed=False) - - for url in domain_blacklist: - if url.lower() in text: - return True - - return False - - @staticmethod - async def _has_zalgo(text: str) -> bool: - """ - Returns True if the text contains zalgo characters. - - Zalgo range is \u0300 – \u036F and \u0489. - """ - return bool(ZALGO_RE.search(text)) - - async def _has_invites(self, text: str) -> Union[dict, bool]: - """ - Checks if there's any invites in the text content that aren't in the guild whitelist. - - If any are detected, a dictionary of invite data is returned, with a key per invite. - If none are detected, False is returned. - - Attempts to catch some of common ways to try to cheat the system. - """ - # Remove backslashes to prevent escape character aroundfuckery like - # discord\.gg/gdudes-pony-farm - text = text.replace("\\", "") - - invites = INVITE_RE.findall(text) - invite_data = dict() - for invite in invites: - if invite in invite_data: - continue - - response = await self.bot.http_session.get( - f"{URLs.discord_invite_api}/{invite}", params={"with_counts": "true"} - ) - response = await response.json() - guild = response.get("guild") - if guild is None: - # Lack of a "guild" key in the JSON response indicates either an group DM invite, an - # expired invite, or an invalid invite. The API does not currently differentiate - # between invalid and expired invites - return True - - guild_id = guild.get("id") - guild_invite_whitelist = self._get_filterlist_items("guild_invite", allowed=True) - guild_invite_blacklist = self._get_filterlist_items("guild_invite", allowed=False) - - # Is this invite allowed? - guild_partnered_or_verified = ( - 'PARTNERED' in guild.get("features", []) - or 'VERIFIED' in guild.get("features", []) - ) - invite_not_allowed = ( - guild_id in guild_invite_blacklist # Blacklisted guilds are never permitted. - or guild_id not in guild_invite_whitelist # Whitelisted guilds are always permitted. - and not guild_partnered_or_verified # Otherwise guilds have to be Verified or Partnered. - ) - - if invite_not_allowed: - guild_icon_hash = guild["icon"] - guild_icon = ( - "https://cdn.discordapp.com/icons/" - f"{guild_id}/{guild_icon_hash}.png?size=512" - ) - - invite_data[invite] = { - "name": guild["name"], - "id": guild['id'], - "icon": guild_icon, - "members": response["approximate_member_count"], - "active": response["approximate_presence_count"] - } - - return invite_data if invite_data else False - - @staticmethod - async def _has_rich_embed(msg: Message) -> Union[bool, List[discord.Embed]]: - """Determines if `msg` contains any rich embeds not auto-generated from a URL.""" - if msg.embeds: - for embed in msg.embeds: - if embed.type == "rich": - urls = URL_RE.findall(msg.content) - if not embed.url or embed.url not in urls: - # If `embed.url` does not exist or if `embed.url` is not part of the content - # of the message, it's unlikely to be an auto-generated embed by Discord. - return msg.embeds - else: - log.trace( - "Found a rich embed sent by a regular user account, " - "but it was likely just an automatic URL embed." - ) - return False - return False - - async def notify_member(self, filtered_member: Member, reason: str, channel: TextChannel) -> None: - """ - Notify filtered_member about a moderation action with the reason str. - - First attempts to DM the user, fall back to in-channel notification if user has DMs disabled - """ - try: - await filtered_member.send(reason) - except discord.errors.Forbidden: - await channel.send(f"{filtered_member.mention} {reason}") - - def schedule_msg_delete(self, msg: dict) -> None: - """Delete an offensive message once its deletion date is reached.""" - delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None) - self.scheduler.schedule_at(delete_at, msg['id'], self.delete_offensive_msg(msg)) - - async def reschedule_offensive_msg_deletion(self) -> None: - """Get all the pending message deletion from the API and reschedule them.""" - await self.bot.wait_until_ready() - response = await self.bot.api_client.get('bot/offensive-messages',) - - now = datetime.utcnow() - - for msg in response: - delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None) - - if delete_at < now: - await self.delete_offensive_msg(msg) - else: - self.schedule_msg_delete(msg) - - async def delete_offensive_msg(self, msg: Mapping[str, str]) -> None: - """Delete an offensive message, and then delete it from the db.""" - try: - channel = self.bot.get_channel(msg['channel_id']) - if channel: - msg_obj = await channel.fetch_message(msg['id']) - await msg_obj.delete() - except NotFound: - log.info( - f"Tried to delete message {msg['id']}, but the message can't be found " - f"(it has been probably already deleted)." - ) - except HTTPException as e: - log.warning(f"Failed to delete message {msg['id']}: status {e.status}") - - await self.bot.api_client.delete(f'bot/offensive-messages/{msg["id"]}') - log.info(f"Deleted the offensive message with id {msg['id']}.") - - -def setup(bot: Bot) -> None: - """Load the Filtering cog.""" - bot.add_cog(Filtering(bot)) diff --git a/bot/cogs/filters/__init__.py b/bot/cogs/filters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bot/cogs/filters/antimalware.py b/bot/cogs/filters/antimalware.py new file mode 100644 index 000000000..c76bd2c60 --- /dev/null +++ b/bot/cogs/filters/antimalware.py @@ -0,0 +1,98 @@ +import logging +import typing as t +from os.path import splitext + +from discord import Embed, Message, NotFound +from discord.ext.commands import Cog + +from bot.bot import Bot +from bot.constants import Channels, STAFF_ROLES, URLs + +log = logging.getLogger(__name__) + +PY_EMBED_DESCRIPTION = ( + "It looks like you tried to attach a Python file - " + f"please use a code-pasting service such as {URLs.site_schema}{URLs.site_paste}" +) + +TXT_EMBED_DESCRIPTION = ( + "**Uh-oh!** It looks like your message got zapped by our spam filter. " + "We currently don't allow `.txt` attachments, so here are some tips to help you travel safely: \n\n" + "• If you attempted to send a message longer than 2000 characters, try shortening your message " + "to fit within the character limit or use a pasting service (see below) \n\n" + "• If you tried to show someone your code, you can use codeblocks \n(run `!code-blocks` in " + "{cmd_channel_mention} for more information) or use a pasting service like: " + f"\n\n{URLs.site_schema}{URLs.site_paste}" +) + +DISALLOWED_EMBED_DESCRIPTION = ( + "It looks like you tried to attach file type(s) that we do not allow ({blocked_extensions_str}). " + "We currently allow the following file types: **{joined_whitelist}**.\n\n" + "Feel free to ask in {meta_channel_mention} if you think this is a mistake." +) + + +class AntiMalware(Cog): + """Delete messages which contain attachments with non-whitelisted file extensions.""" + + def __init__(self, bot: Bot): + self.bot = bot + + def _get_whitelisted_file_formats(self) -> list: + """Get the file formats currently on the whitelist.""" + return self.bot.filter_list_cache['FILE_FORMAT.True'].keys() + + def _get_disallowed_extensions(self, message: Message) -> t.Iterable[str]: + """Get an iterable containing all the disallowed extensions of attachments.""" + file_extensions = {splitext(attachment.filename.lower())[1] for attachment in message.attachments} + extensions_blocked = file_extensions - set(self._get_whitelisted_file_formats()) + return extensions_blocked + + @Cog.listener() + async def on_message(self, message: Message) -> None: + """Identify messages with prohibited attachments.""" + # Return when message don't have attachment and don't moderate DMs + if not message.attachments or not message.guild: + return + + # Check if user is staff, if is, return + # Since we only care that roles exist to iterate over, check for the attr rather than a User/Member instance + if hasattr(message.author, "roles") and any(role.id in STAFF_ROLES for role in message.author.roles): + return + + embed = Embed() + extensions_blocked = self._get_disallowed_extensions(message) + blocked_extensions_str = ', '.join(extensions_blocked) + if ".py" in extensions_blocked: + # Short-circuit on *.py files to provide a pastebin link + embed.description = PY_EMBED_DESCRIPTION + elif ".txt" in extensions_blocked: + # Work around Discord AutoConversion of messages longer than 2000 chars to .txt + cmd_channel = self.bot.get_channel(Channels.bot_commands) + embed.description = TXT_EMBED_DESCRIPTION.format(cmd_channel_mention=cmd_channel.mention) + elif extensions_blocked: + meta_channel = self.bot.get_channel(Channels.meta) + embed.description = DISALLOWED_EMBED_DESCRIPTION.format( + joined_whitelist=', '.join(self._get_whitelisted_file_formats()), + blocked_extensions_str=blocked_extensions_str, + meta_channel_mention=meta_channel.mention, + ) + + if embed.description: + log.info( + f"User '{message.author}' ({message.author.id}) uploaded blacklisted file(s): {blocked_extensions_str}", + extra={"attachment_list": [attachment.filename for attachment in message.attachments]} + ) + + await message.channel.send(f"Hey {message.author.mention}!", embed=embed) + + # Delete the offending message: + try: + await message.delete() + except NotFound: + log.info(f"Tried to delete message `{message.id}`, but message could not be found.") + + +def setup(bot: Bot) -> None: + """Load the AntiMalware cog.""" + bot.add_cog(AntiMalware(bot)) diff --git a/bot/cogs/filters/antispam.py b/bot/cogs/filters/antispam.py new file mode 100644 index 000000000..0bcca578d --- /dev/null +++ b/bot/cogs/filters/antispam.py @@ -0,0 +1,288 @@ +import asyncio +import logging +from collections.abc import Mapping +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from operator import itemgetter +from typing import Dict, Iterable, List, Set + +from discord import Colour, Member, Message, NotFound, Object, TextChannel +from discord.ext.commands import Cog + +from bot import rules +from bot.bot import Bot +from bot.cogs.moderation import ModLog +from bot.constants import ( + AntiSpam as AntiSpamConfig, Channels, + Colours, DEBUG_MODE, Event, Filter, + Guild as GuildConfig, Icons, + STAFF_ROLES, +) +from bot.converters import Duration +from bot.utils.messages import send_attachments + + +log = logging.getLogger(__name__) + +RULE_FUNCTION_MAPPING = { + 'attachments': rules.apply_attachments, + 'burst': rules.apply_burst, + 'burst_shared': rules.apply_burst_shared, + 'chars': rules.apply_chars, + 'discord_emojis': rules.apply_discord_emojis, + 'duplicates': rules.apply_duplicates, + 'links': rules.apply_links, + 'mentions': rules.apply_mentions, + 'newlines': rules.apply_newlines, + 'role_mentions': rules.apply_role_mentions +} + + +@dataclass +class DeletionContext: + """Represents a Deletion Context for a single spam event.""" + + channel: TextChannel + members: Dict[int, Member] = field(default_factory=dict) + rules: Set[str] = field(default_factory=set) + messages: Dict[int, Message] = field(default_factory=dict) + attachments: List[List[str]] = field(default_factory=list) + + async def add(self, rule_name: str, members: Iterable[Member], messages: Iterable[Message]) -> None: + """Adds new rule violation events to the deletion context.""" + self.rules.add(rule_name) + + for member in members: + if member.id not in self.members: + self.members[member.id] = member + + for message in messages: + if message.id not in self.messages: + self.messages[message.id] = message + + # Re-upload attachments + destination = message.guild.get_channel(Channels.attachment_log) + urls = await send_attachments(message, destination, link_large=False) + self.attachments.append(urls) + + async def upload_messages(self, actor_id: int, modlog: ModLog) -> None: + """Method that takes care of uploading the queue and posting modlog alert.""" + triggered_by_users = ", ".join(f"{m} (`{m.id}`)" for m in self.members.values()) + + mod_alert_message = ( + f"**Triggered by:** {triggered_by_users}\n" + f"**Channel:** {self.channel.mention}\n" + f"**Rules:** {', '.join(rule for rule in self.rules)}\n" + ) + + # For multiple messages or those with excessive newlines, use the logs API + if len(self.messages) > 1 or 'newlines' in self.rules: + url = await modlog.upload_log(self.messages.values(), actor_id, self.attachments) + mod_alert_message += f"A complete log of the offending messages can be found [here]({url})" + else: + mod_alert_message += "Message:\n" + [message] = self.messages.values() + content = message.clean_content + remaining_chars = 2040 - len(mod_alert_message) + + if len(content) > remaining_chars: + content = content[:remaining_chars] + "..." + + mod_alert_message += f"{content}" + + *_, last_message = self.messages.values() + await modlog.send_log_message( + icon_url=Icons.filtering, + colour=Colour(Colours.soft_red), + title="Spam detected!", + text=mod_alert_message, + thumbnail=last_message.author.avatar_url_as(static_format="png"), + channel_id=Channels.mod_alerts, + ping_everyone=AntiSpamConfig.ping_everyone + ) + + +class AntiSpam(Cog): + """Cog that controls our anti-spam measures.""" + + def __init__(self, bot: Bot, validation_errors: Dict[str, str]) -> None: + self.bot = bot + self.validation_errors = validation_errors + role_id = AntiSpamConfig.punishment['role_id'] + self.muted_role = Object(role_id) + self.expiration_date_converter = Duration() + + self.message_deletion_queue = dict() + + self.bot.loop.create_task(self.alert_on_validation_error()) + + @property + def mod_log(self) -> ModLog: + """Allows for easy access of the ModLog cog.""" + return self.bot.get_cog("ModLog") + + async def alert_on_validation_error(self) -> None: + """Unloads the cog and alerts admins if configuration validation failed.""" + await self.bot.wait_until_guild_available() + if self.validation_errors: + body = "**The following errors were encountered:**\n" + body += "\n".join(f"- {error}" for error in self.validation_errors.values()) + body += "\n\n**The cog has been unloaded.**" + + await self.mod_log.send_log_message( + title="Error: AntiSpam configuration validation failed!", + text=body, + ping_everyone=True, + icon_url=Icons.token_removed, + colour=Colour.red() + ) + + self.bot.remove_cog(self.__class__.__name__) + return + + @Cog.listener() + async def on_message(self, message: Message) -> None: + """Applies the antispam rules to each received message.""" + if ( + not message.guild + or message.guild.id != GuildConfig.id + or message.author.bot + or (message.channel.id in Filter.channel_whitelist and not DEBUG_MODE) + or (any(role.id in STAFF_ROLES for role in message.author.roles) and not DEBUG_MODE) + ): + return + + # Fetch the rule configuration with the highest rule interval. + max_interval_config = max( + AntiSpamConfig.rules.values(), + key=itemgetter('interval') + ) + max_interval = max_interval_config['interval'] + + # Store history messages since `interval` seconds ago in a list to prevent unnecessary API calls. + earliest_relevant_at = datetime.utcnow() - timedelta(seconds=max_interval) + relevant_messages = [ + msg async for msg in message.channel.history(after=earliest_relevant_at, oldest_first=False) + if not msg.author.bot + ] + + for rule_name in AntiSpamConfig.rules: + rule_config = AntiSpamConfig.rules[rule_name] + rule_function = RULE_FUNCTION_MAPPING[rule_name] + + # Create a list of messages that were sent in the interval that the rule cares about. + latest_interesting_stamp = datetime.utcnow() - timedelta(seconds=rule_config['interval']) + messages_for_rule = [ + msg for msg in relevant_messages if msg.created_at > latest_interesting_stamp + ] + result = await rule_function(message, messages_for_rule, rule_config) + + # If the rule returns `None`, that means the message didn't violate it. + # If it doesn't, it returns a tuple in the form `(str, Iterable[discord.Member])` + # which contains the reason for why the message violated the rule and + # an iterable of all members that violated the rule. + if result is not None: + self.bot.stats.incr(f"mod_alerts.{rule_name}") + reason, members, relevant_messages = result + full_reason = f"`{rule_name}` rule: {reason}" + + # If there's no spam event going on for this channel, start a new Message Deletion Context + channel = message.channel + if channel.id not in self.message_deletion_queue: + log.trace(f"Creating queue for channel `{channel.id}`") + self.message_deletion_queue[message.channel.id] = DeletionContext(channel) + self.bot.loop.create_task(self._process_deletion_context(message.channel.id)) + + # Add the relevant of this trigger to the Deletion Context + await self.message_deletion_queue[message.channel.id].add( + rule_name=rule_name, + members=members, + messages=relevant_messages + ) + + for member in members: + + # Fire it off as a background task to ensure + # that the sleep doesn't block further tasks + self.bot.loop.create_task( + self.punish(message, member, full_reason) + ) + + await self.maybe_delete_messages(channel, relevant_messages) + break + + async def punish(self, msg: Message, member: Member, reason: str) -> None: + """Punishes the given member for triggering an antispam rule.""" + if not any(role.id == self.muted_role.id for role in member.roles): + remove_role_after = AntiSpamConfig.punishment['remove_after'] + + # Get context and make sure the bot becomes the actor of infraction by patching the `author` attributes + context = await self.bot.get_context(msg) + context.author = self.bot.user + context.message.author = self.bot.user + + # Since we're going to invoke the tempmute command directly, we need to manually call the converter. + dt_remove_role_after = await self.expiration_date_converter.convert(context, f"{remove_role_after}S") + await context.invoke( + self.bot.get_command('tempmute'), + member, + dt_remove_role_after, + reason=reason + ) + + async def maybe_delete_messages(self, channel: TextChannel, messages: List[Message]) -> None: + """Cleans the messages if cleaning is configured.""" + if AntiSpamConfig.clean_offending: + # If we have more than one message, we can use bulk delete. + if len(messages) > 1: + message_ids = [message.id for message in messages] + self.mod_log.ignore(Event.message_delete, *message_ids) + await channel.delete_messages(messages) + + # Otherwise, the bulk delete endpoint will throw up. + # Delete the message directly instead. + else: + self.mod_log.ignore(Event.message_delete, messages[0].id) + try: + await messages[0].delete() + except NotFound: + log.info(f"Tried to delete message `{messages[0].id}`, but message could not be found.") + + async def _process_deletion_context(self, context_id: int) -> None: + """Processes the Deletion Context queue.""" + log.trace("Sleeping before processing message deletion queue.") + await asyncio.sleep(10) + + if context_id not in self.message_deletion_queue: + log.error(f"Started processing deletion queue for context `{context_id}`, but it was not found!") + return + + deletion_context = self.message_deletion_queue.pop(context_id) + await deletion_context.upload_messages(self.bot.user.id, self.mod_log) + + +def validate_config(rules_: Mapping = AntiSpamConfig.rules) -> Dict[str, str]: + """Validates the antispam configs.""" + validation_errors = {} + for name, config in rules_.items(): + if name not in RULE_FUNCTION_MAPPING: + log.error( + f"Unrecognized antispam rule `{name}`. " + f"Valid rules are: {', '.join(RULE_FUNCTION_MAPPING)}" + ) + validation_errors[name] = f"`{name}` is not recognized as an antispam rule." + continue + for required_key in ('interval', 'max'): + if required_key not in config: + log.error( + f"`{required_key}` is required but was not " + f"set in rule `{name}`'s configuration." + ) + validation_errors[name] = f"Key `{required_key}` is required but not set for rule `{name}`" + return validation_errors + + +def setup(bot: Bot) -> None: + """Validate the AntiSpam configs and load the AntiSpam cog.""" + validation_errors = validate_config() + bot.add_cog(AntiSpam(bot, validation_errors)) diff --git a/bot/cogs/filters/filter_lists.py b/bot/cogs/filters/filter_lists.py new file mode 100644 index 000000000..c15adc461 --- /dev/null +++ b/bot/cogs/filters/filter_lists.py @@ -0,0 +1,273 @@ +import logging +from typing import Optional + +from discord import Colour, Embed +from discord.ext.commands import BadArgument, Cog, Context, IDConverter, group + +from bot import constants +from bot.api import ResponseCodeError +from bot.bot import Bot +from bot.converters import ValidDiscordServerInvite, ValidFilterListType +from bot.pagination import LinePaginator +from bot.utils.checks import with_role_check + +log = logging.getLogger(__name__) + + +class FilterLists(Cog): + """Commands for blacklisting and whitelisting things.""" + + methods_with_filterlist_types = [ + "allow_add", + "allow_delete", + "allow_get", + "deny_add", + "deny_delete", + "deny_get", + ] + + def __init__(self, bot: Bot) -> None: + self.bot = bot + self.bot.loop.create_task(self._amend_docstrings()) + + async def _amend_docstrings(self) -> None: + """Add the valid FilterList types to the docstrings, so they'll appear in !help invocations.""" + await self.bot.wait_until_guild_available() + + # Add valid filterlist types to the docstrings + valid_types = await ValidFilterListType.get_valid_types(self.bot) + valid_types = [f"`{type_.lower()}`" for type_ in valid_types] + + for method_name in self.methods_with_filterlist_types: + command = getattr(self, method_name) + command.help = ( + f"{command.help}\n\nValid **list_type** values are {', '.join(valid_types)}." + ) + + async def _add_data( + self, + ctx: Context, + allowed: bool, + list_type: ValidFilterListType, + content: str, + comment: Optional[str] = None, + ) -> None: + """Add an item to a filterlist.""" + allow_type = "whitelist" if allowed else "blacklist" + + # If this is a server invite, we gotta validate it. + if list_type == "GUILD_INVITE": + guild_data = await self._validate_guild_invite(ctx, content) + content = guild_data.get("id") + + # Unless the user has specified another comment, let's + # use the server name as the comment so that the list + # of guild IDs will be more easily readable when we + # display it. + if not comment: + comment = guild_data.get("name") + + # If it's a file format, let's make sure it has a leading dot. + elif list_type == "FILE_FORMAT" and not content.startswith("."): + content = f".{content}" + + # Try to add the item to the database + log.trace(f"Trying to add the {content} item to the {list_type} {allow_type}") + payload = { + "allowed": allowed, + "type": list_type, + "content": content, + "comment": comment, + } + + try: + item = await self.bot.api_client.post( + "bot/filter-lists", + json=payload + ) + except ResponseCodeError as e: + if e.status == 400: + await ctx.message.add_reaction("❌") + log.debug( + f"{ctx.author} tried to add data to a {allow_type}, but the API returned 400, " + "probably because the request violated the UniqueConstraint." + ) + raise BadArgument( + f"Unable to add the item to the {allow_type}. " + "The item probably already exists. Keep in mind that a " + "blacklist and a whitelist for the same item cannot co-exist, " + "and we do not permit any duplicates." + ) + raise + + # Insert the item into the cache + self.bot.insert_item_into_filter_list_cache(item) + await ctx.message.add_reaction("✅") + + async def _delete_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType, content: str) -> None: + """Remove an item from a filterlist.""" + allow_type = "whitelist" if allowed else "blacklist" + + # If this is a server invite, we need to convert it. + if list_type == "GUILD_INVITE" and not IDConverter()._get_id_match(content): + guild_data = await self._validate_guild_invite(ctx, content) + content = guild_data.get("id") + + # If it's a file format, let's make sure it has a leading dot. + elif list_type == "FILE_FORMAT" and not content.startswith("."): + content = f".{content}" + + # Find the content and delete it. + log.trace(f"Trying to delete the {content} item from the {list_type} {allow_type}") + item = self.bot.filter_list_cache[f"{list_type}.{allowed}"].get(content) + + if item is not None: + try: + await self.bot.api_client.delete( + f"bot/filter-lists/{item['id']}" + ) + del self.bot.filter_list_cache[f"{list_type}.{allowed}"][content] + await ctx.message.add_reaction("✅") + except ResponseCodeError as e: + log.debug( + f"{ctx.author} tried to delete an item with the id {item['id']}, but " + f"the API raised an unexpected error: {e}" + ) + await ctx.message.add_reaction("❌") + else: + await ctx.message.add_reaction("❌") + + async def _list_all_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType) -> None: + """Paginate and display all items in a filterlist.""" + allow_type = "whitelist" if allowed else "blacklist" + result = self.bot.filter_list_cache[f"{list_type}.{allowed}"] + + # Build a list of lines we want to show in the paginator + lines = [] + for content, metadata in result.items(): + line = f"• `{content}`" + + if comment := metadata.get("comment"): + line += f" - {comment}" + + lines.append(line) + lines = sorted(lines) + + # Build the embed + list_type_plural = list_type.lower().replace("_", " ").title() + "s" + embed = Embed( + title=f"{allow_type.title()}ed {list_type_plural} ({len(result)} total)", + colour=Colour.blue() + ) + log.trace(f"Trying to list {len(result)} items from the {list_type.lower()} {allow_type}") + + if result: + await LinePaginator.paginate(lines, ctx, embed, max_lines=15, empty=False) + else: + embed.description = "Hmmm, seems like there's nothing here yet." + await ctx.send(embed=embed) + await ctx.message.add_reaction("❌") + + async def _sync_data(self, ctx: Context) -> None: + """Syncs the filterlists with the API.""" + try: + log.trace("Attempting to sync FilterList cache with data from the API.") + await self.bot.cache_filter_list_data() + await ctx.message.add_reaction("✅") + except ResponseCodeError as e: + log.debug( + f"{ctx.author} tried to sync FilterList cache data but " + f"the API raised an unexpected error: {e}" + ) + await ctx.message.add_reaction("❌") + + @staticmethod + async def _validate_guild_invite(ctx: Context, invite: str) -> dict: + """ + Validates a guild invite, and returns the guild info as a dict. + + Will raise a BadArgument if the guild invite is invalid. + """ + log.trace(f"Attempting to validate whether or not {invite} is a guild invite.") + validator = ValidDiscordServerInvite() + guild_data = await validator.convert(ctx, invite) + + # If we make it this far without raising a BadArgument, the invite is + # valid. Let's return a dict of guild information. + log.trace(f"{invite} validated as server invite. Converting to ID.") + return guild_data + + @group(aliases=("allowlist", "allow", "al", "wl")) + async def whitelist(self, ctx: Context) -> None: + """Group for whitelisting commands.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @group(aliases=("denylist", "deny", "bl", "dl")) + async def blacklist(self, ctx: Context) -> None: + """Group for blacklisting commands.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @whitelist.command(name="add", aliases=("a", "set")) + async def allow_add( + self, + ctx: Context, + list_type: ValidFilterListType, + content: str, + *, + comment: Optional[str] = None, + ) -> None: + """Add an item to the specified allowlist.""" + await self._add_data(ctx, True, list_type, content, comment) + + @blacklist.command(name="add", aliases=("a", "set")) + async def deny_add( + self, + ctx: Context, + list_type: ValidFilterListType, + content: str, + *, + comment: Optional[str] = None, + ) -> None: + """Add an item to the specified denylist.""" + await self._add_data(ctx, False, list_type, content, comment) + + @whitelist.command(name="remove", aliases=("delete", "rm",)) + async def allow_delete(self, ctx: Context, list_type: ValidFilterListType, content: str) -> None: + """Remove an item from the specified allowlist.""" + await self._delete_data(ctx, True, list_type, content) + + @blacklist.command(name="remove", aliases=("delete", "rm",)) + async def deny_delete(self, ctx: Context, list_type: ValidFilterListType, content: str) -> None: + """Remove an item from the specified denylist.""" + await self._delete_data(ctx, False, list_type, content) + + @whitelist.command(name="get", aliases=("list", "ls", "fetch", "show")) + async def allow_get(self, ctx: Context, list_type: ValidFilterListType) -> None: + """Get the contents of a specified allowlist.""" + await self._list_all_data(ctx, True, list_type) + + @blacklist.command(name="get", aliases=("list", "ls", "fetch", "show")) + async def deny_get(self, ctx: Context, list_type: ValidFilterListType) -> None: + """Get the contents of a specified denylist.""" + await self._list_all_data(ctx, False, list_type) + + @whitelist.command(name="sync", aliases=("s",)) + async def allow_sync(self, ctx: Context) -> None: + """Syncs both allowlists and denylists with the API.""" + await self._sync_data(ctx) + + @blacklist.command(name="sync", aliases=("s",)) + async def deny_sync(self, ctx: Context) -> None: + """Syncs both allowlists and denylists with the API.""" + await self._sync_data(ctx) + + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + return with_role_check(ctx, *constants.MODERATION_ROLES) + + +def setup(bot: Bot) -> None: + """Load the FilterLists cog.""" + bot.add_cog(FilterLists(bot)) diff --git a/bot/cogs/filters/filtering.py b/bot/cogs/filters/filtering.py new file mode 100644 index 000000000..93cc1c655 --- /dev/null +++ b/bot/cogs/filters/filtering.py @@ -0,0 +1,575 @@ +import asyncio +import logging +import re +from datetime import datetime, timedelta +from typing import List, Mapping, Optional, Tuple, Union + +import dateutil +import discord.errors +from dateutil.relativedelta import relativedelta +from discord import Colour, HTTPException, Member, Message, NotFound, TextChannel +from discord.ext.commands import Cog +from discord.utils import escape_markdown + +from bot.bot import Bot +from bot.cogs.moderation import ModLog +from bot.constants import ( + Channels, Colours, + Filter, Icons, URLs +) +from bot.utils.redis_cache import RedisCache +from bot.utils.regex import INVITE_RE +from bot.utils.scheduling import Scheduler + +log = logging.getLogger(__name__) + +# Regular expressions +SPOILER_RE = re.compile(r"(\|\|.+?\|\|)", re.DOTALL) +URL_RE = re.compile(r"(https?://[^\s]+)", flags=re.IGNORECASE) +ZALGO_RE = re.compile(r"[\u0300-\u036F\u0489]") + +# Other constants. +DAYS_BETWEEN_ALERTS = 3 +OFFENSIVE_MSG_DELETE_TIME = timedelta(days=Filter.offensive_msg_delete_days) + + +class Filtering(Cog): + """Filtering out invites, blacklisting domains, and warning us of certain regular expressions.""" + + # Redis cache mapping a user ID to the last timestamp a bad nickname alert was sent + name_alerts = RedisCache() + + def __init__(self, bot: Bot): + self.bot = bot + self.scheduler = Scheduler(self.__class__.__name__) + self.name_lock = asyncio.Lock() + + staff_mistake_str = "If you believe this was a mistake, please let staff know!" + self.filters = { + "filter_zalgo": { + "enabled": Filter.filter_zalgo, + "function": self._has_zalgo, + "type": "filter", + "content_only": True, + "user_notification": Filter.notify_user_zalgo, + "notification_msg": ( + "Your post has been removed for abusing Unicode character rendering (aka Zalgo text). " + f"{staff_mistake_str}" + ), + "schedule_deletion": False + }, + "filter_invites": { + "enabled": Filter.filter_invites, + "function": self._has_invites, + "type": "filter", + "content_only": True, + "user_notification": Filter.notify_user_invites, + "notification_msg": ( + f"Per Rule 6, your invite link has been removed. {staff_mistake_str}\n\n" + r"Our server rules can be found here: " + ), + "schedule_deletion": False + }, + "filter_domains": { + "enabled": Filter.filter_domains, + "function": self._has_urls, + "type": "filter", + "content_only": True, + "user_notification": Filter.notify_user_domains, + "notification_msg": ( + f"Your URL has been removed because it matched a blacklisted domain. {staff_mistake_str}" + ), + "schedule_deletion": False + }, + "watch_regex": { + "enabled": Filter.watch_regex, + "function": self._has_watch_regex_match, + "type": "watchlist", + "content_only": True, + "schedule_deletion": True + }, + "watch_rich_embeds": { + "enabled": Filter.watch_rich_embeds, + "function": self._has_rich_embed, + "type": "watchlist", + "content_only": False, + "schedule_deletion": False + } + } + + self.bot.loop.create_task(self.reschedule_offensive_msg_deletion()) + + def cog_unload(self) -> None: + """Cancel scheduled tasks.""" + self.scheduler.cancel_all() + + def _get_filterlist_items(self, list_type: str, *, allowed: bool) -> list: + """Fetch items from the filter_list_cache.""" + return self.bot.filter_list_cache[f"{list_type.upper()}.{allowed}"].keys() + + @staticmethod + def _expand_spoilers(text: str) -> str: + """Return a string containing all interpretations of a spoilered message.""" + split_text = SPOILER_RE.split(text) + return ''.join( + split_text[0::2] + split_text[1::2] + split_text + ) + + @property + def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + @Cog.listener() + async def on_message(self, msg: Message) -> None: + """Invoke message filter for new messages.""" + await self._filter_message(msg) + + # Ignore webhook messages. + if msg.webhook_id is None: + await self.check_bad_words_in_name(msg.author) + + @Cog.listener() + async def on_message_edit(self, before: Message, after: Message) -> None: + """ + Invoke message filter for message edits. + + If there have been multiple edits, calculate the time delta from the previous edit. + """ + if not before.edited_at: + delta = relativedelta(after.edited_at, before.created_at).microseconds + else: + delta = relativedelta(after.edited_at, before.edited_at).microseconds + await self._filter_message(after, delta) + + def get_name_matches(self, name: str) -> List[re.Match]: + """Check bad words from passed string (name). Return list of matches.""" + matches = [] + watchlist_patterns = self._get_filterlist_items('filter_token', allowed=False) + for pattern in watchlist_patterns: + if match := re.search(pattern, name, flags=re.IGNORECASE): + matches.append(match) + return matches + + async def check_send_alert(self, member: Member) -> bool: + """When there is less than 3 days after last alert, return `False`, otherwise `True`.""" + if last_alert := await self.name_alerts.get(member.id): + last_alert = datetime.utcfromtimestamp(last_alert) + if datetime.utcnow() - timedelta(days=DAYS_BETWEEN_ALERTS) < last_alert: + log.trace(f"Last alert was too recent for {member}'s nickname.") + return False + + return True + + async def check_bad_words_in_name(self, member: Member) -> None: + """Send a mod alert every 3 days if a username still matches a watchlist pattern.""" + # Use lock to avoid race conditions + async with self.name_lock: + # Check whether the users display name contains any words in our blacklist + matches = self.get_name_matches(member.display_name) + + if not matches or not await self.check_send_alert(member): + return + + log.info(f"Sending bad nickname alert for '{member.display_name}' ({member.id}).") + + log_string = ( + f"**User:** {member.mention} (`{member.id}`)\n" + f"**Display Name:** {member.display_name}\n" + f"**Bad Matches:** {', '.join(match.group() for match in matches)}" + ) + + await self.mod_log.send_log_message( + icon_url=Icons.token_removed, + colour=Colours.soft_red, + title="Username filtering alert", + text=log_string, + channel_id=Channels.mod_alerts, + thumbnail=member.avatar_url + ) + + # Update time when alert sent + await self.name_alerts.set(member.id, datetime.utcnow().timestamp()) + + async def filter_eval(self, result: str, msg: Message) -> bool: + """ + Filter the result of an !eval to see if it violates any of our rules, and then respond accordingly. + + Also requires the original message, to check whether to filter and for mod logs. + Returns whether a filter was triggered or not. + """ + filter_triggered = False + # Should we filter this message? + if self._check_filter(msg): + for filter_name, _filter in self.filters.items(): + # Is this specific filter enabled in the config? + # We also do not need to worry about filters that take the full message, + # since all we have is an arbitrary string. + if _filter["enabled"] and _filter["content_only"]: + match = await _filter["function"](result) + + if match: + # If this is a filter (not a watchlist), we set the variable so we know + # that it has been triggered + if _filter["type"] == "filter": + filter_triggered = True + + # We do not have to check against DM channels since !eval cannot be used there. + channel_str = f"in {msg.channel.mention}" + + message_content, additional_embeds, additional_embeds_msg = self._add_stats( + filter_name, match, result + ) + + message = ( + f"The {filter_name} {_filter['type']} was triggered " + f"by **{msg.author}** " + f"(`{msg.author.id}`) {channel_str} using !eval with " + f"[the following message]({msg.jump_url}):\n\n" + f"{message_content}" + ) + + log.debug(message) + + # Send pretty mod log embed to mod-alerts + await self.mod_log.send_log_message( + icon_url=Icons.filtering, + colour=Colour(Colours.soft_red), + title=f"{_filter['type'].title()} triggered!", + text=message, + thumbnail=msg.author.avatar_url_as(static_format="png"), + channel_id=Channels.mod_alerts, + ping_everyone=Filter.ping_everyone, + additional_embeds=additional_embeds, + additional_embeds_msg=additional_embeds_msg + ) + + break # We don't want multiple filters to trigger + + return filter_triggered + + async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> None: + """Filter the input message to see if it violates any of our rules, and then respond accordingly.""" + # Should we filter this message? + if self._check_filter(msg): + for filter_name, _filter in self.filters.items(): + # Is this specific filter enabled in the config? + if _filter["enabled"]: + # Double trigger check for the embeds filter + if filter_name == "watch_rich_embeds": + # If the edit delta is less than 0.001 seconds, then we're probably dealing + # with a double filter trigger. + if delta is not None and delta < 100: + continue + + # Does the filter only need the message content or the full message? + if _filter["content_only"]: + match = await _filter["function"](msg.content) + else: + match = await _filter["function"](msg) + + if match: + is_private = msg.channel.type is discord.ChannelType.private + + # If this is a filter (not a watchlist) and not in a DM, delete the message. + if _filter["type"] == "filter" and not is_private: + try: + # Embeds (can?) trigger both the `on_message` and `on_message_edit` + # event handlers, triggering filtering twice for the same message. + # + # If `on_message`-triggered filtering already deleted the message + # then `on_message_edit`-triggered filtering will raise exception + # since the message no longer exists. + # + # In addition, to avoid sending two notifications to the user, the + # logs, and mod_alert, we return if the message no longer exists. + await msg.delete() + except discord.errors.NotFound: + return + + # Notify the user if the filter specifies + if _filter["user_notification"]: + await self.notify_member(msg.author, _filter["notification_msg"], msg.channel) + + # If the message is classed as offensive, we store it in the site db and + # it will be deleted it after one week. + if _filter["schedule_deletion"] and not is_private: + delete_date = (msg.created_at + OFFENSIVE_MSG_DELETE_TIME).isoformat() + data = { + 'id': msg.id, + 'channel_id': msg.channel.id, + 'delete_date': delete_date + } + + await self.bot.api_client.post('bot/offensive-messages', json=data) + self.schedule_msg_delete(data) + log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}") + + if is_private: + channel_str = "via DM" + else: + channel_str = f"in {msg.channel.mention}" + + message_content, additional_embeds, additional_embeds_msg = self._add_stats( + filter_name, match, msg.content + ) + + message = ( + f"The {filter_name} {_filter['type']} was triggered " + f"by **{msg.author}** " + f"(`{msg.author.id}`) {channel_str} with [the " + f"following message]({msg.jump_url}):\n\n" + f"{message_content}" + ) + + log.debug(message) + + # Send pretty mod log embed to mod-alerts + await self.mod_log.send_log_message( + icon_url=Icons.filtering, + colour=Colour(Colours.soft_red), + title=f"{_filter['type'].title()} triggered!", + text=message, + thumbnail=msg.author.avatar_url_as(static_format="png"), + channel_id=Channels.mod_alerts, + ping_everyone=Filter.ping_everyone if not is_private else False, + additional_embeds=additional_embeds, + additional_embeds_msg=additional_embeds_msg + ) + + break # We don't want multiple filters to trigger + + def _add_stats(self, name: str, match: Union[re.Match, dict, bool, List[discord.Embed]], content: str) -> Tuple[ + str, Optional[List[discord.Embed]], Optional[str] + ]: + """Adds relevant statistical information to the relevant filter and increments the bot's stats.""" + # Word and match stats for watch_regex + if name == "watch_regex": + surroundings = match.string[max(match.start() - 10, 0): match.end() + 10] + message_content = ( + f"**Match:** '{match[0]}'\n" + f"**Location:** '...{escape_markdown(surroundings)}...'\n" + f"\n**Original Message:**\n{escape_markdown(content)}" + ) + else: # Use original content + message_content = content + + additional_embeds = None + additional_embeds_msg = None + + self.bot.stats.incr(f"filters.{name}") + + # The function returns True for invalid invites. + # They have no data so additional embeds can't be created for them. + if name == "filter_invites" and match is not True: + additional_embeds = [] + for _, data in match.items(): + embed = discord.Embed(description=( + f"**Members:**\n{data['members']}\n" + f"**Active:**\n{data['active']}" + )) + embed.set_author(name=data["name"]) + embed.set_thumbnail(url=data["icon"]) + embed.set_footer(text=f"Guild ID: {data['id']}") + additional_embeds.append(embed) + additional_embeds_msg = "For the following guild(s):" + + elif name == "watch_rich_embeds": + additional_embeds = match + additional_embeds_msg = "With the following embed(s):" + + return message_content, additional_embeds, additional_embeds_msg + + @staticmethod + def _check_filter(msg: Message) -> bool: + """Check whitelists to see if we should filter this message.""" + role_whitelisted = False + + if type(msg.author) is Member: # Only Member has roles, not User. + for role in msg.author.roles: + if role.id in Filter.role_whitelist: + role_whitelisted = True + + return ( + msg.channel.id not in Filter.channel_whitelist # Channel not in whitelist + and not role_whitelisted # Role not in whitelist + and not msg.author.bot # Author not a bot + ) + + async def _has_watch_regex_match(self, text: str) -> Union[bool, re.Match]: + """ + Return True if `text` matches any regex from `word_watchlist` or `token_watchlist` configs. + + `word_watchlist`'s patterns are placed between word boundaries while `token_watchlist` is + matched as-is. Spoilers are expanded, if any, and URLs are ignored. + """ + if SPOILER_RE.search(text): + text = self._expand_spoilers(text) + + # Make sure it's not a URL + if URL_RE.search(text): + return False + + watchlist_patterns = self._get_filterlist_items('filter_token', allowed=False) + for pattern in watchlist_patterns: + match = re.search(pattern, text, flags=re.IGNORECASE) + if match: + return match + + async def _has_urls(self, text: str) -> bool: + """Returns True if the text contains one of the blacklisted URLs from the config file.""" + if not URL_RE.search(text): + return False + + text = text.lower() + domain_blacklist = self._get_filterlist_items("domain_name", allowed=False) + + for url in domain_blacklist: + if url.lower() in text: + return True + + return False + + @staticmethod + async def _has_zalgo(text: str) -> bool: + """ + Returns True if the text contains zalgo characters. + + Zalgo range is \u0300 – \u036F and \u0489. + """ + return bool(ZALGO_RE.search(text)) + + async def _has_invites(self, text: str) -> Union[dict, bool]: + """ + Checks if there's any invites in the text content that aren't in the guild whitelist. + + If any are detected, a dictionary of invite data is returned, with a key per invite. + If none are detected, False is returned. + + Attempts to catch some of common ways to try to cheat the system. + """ + # Remove backslashes to prevent escape character aroundfuckery like + # discord\.gg/gdudes-pony-farm + text = text.replace("\\", "") + + invites = INVITE_RE.findall(text) + invite_data = dict() + for invite in invites: + if invite in invite_data: + continue + + response = await self.bot.http_session.get( + f"{URLs.discord_invite_api}/{invite}", params={"with_counts": "true"} + ) + response = await response.json() + guild = response.get("guild") + if guild is None: + # Lack of a "guild" key in the JSON response indicates either an group DM invite, an + # expired invite, or an invalid invite. The API does not currently differentiate + # between invalid and expired invites + return True + + guild_id = guild.get("id") + guild_invite_whitelist = self._get_filterlist_items("guild_invite", allowed=True) + guild_invite_blacklist = self._get_filterlist_items("guild_invite", allowed=False) + + # Is this invite allowed? + guild_partnered_or_verified = ( + 'PARTNERED' in guild.get("features", []) + or 'VERIFIED' in guild.get("features", []) + ) + invite_not_allowed = ( + guild_id in guild_invite_blacklist # Blacklisted guilds are never permitted. + or guild_id not in guild_invite_whitelist # Whitelisted guilds are always permitted. + and not guild_partnered_or_verified # Otherwise guilds have to be Verified or Partnered. + ) + + if invite_not_allowed: + guild_icon_hash = guild["icon"] + guild_icon = ( + "https://cdn.discordapp.com/icons/" + f"{guild_id}/{guild_icon_hash}.png?size=512" + ) + + invite_data[invite] = { + "name": guild["name"], + "id": guild['id'], + "icon": guild_icon, + "members": response["approximate_member_count"], + "active": response["approximate_presence_count"] + } + + return invite_data if invite_data else False + + @staticmethod + async def _has_rich_embed(msg: Message) -> Union[bool, List[discord.Embed]]: + """Determines if `msg` contains any rich embeds not auto-generated from a URL.""" + if msg.embeds: + for embed in msg.embeds: + if embed.type == "rich": + urls = URL_RE.findall(msg.content) + if not embed.url or embed.url not in urls: + # If `embed.url` does not exist or if `embed.url` is not part of the content + # of the message, it's unlikely to be an auto-generated embed by Discord. + return msg.embeds + else: + log.trace( + "Found a rich embed sent by a regular user account, " + "but it was likely just an automatic URL embed." + ) + return False + return False + + async def notify_member(self, filtered_member: Member, reason: str, channel: TextChannel) -> None: + """ + Notify filtered_member about a moderation action with the reason str. + + First attempts to DM the user, fall back to in-channel notification if user has DMs disabled + """ + try: + await filtered_member.send(reason) + except discord.errors.Forbidden: + await channel.send(f"{filtered_member.mention} {reason}") + + def schedule_msg_delete(self, msg: dict) -> None: + """Delete an offensive message once its deletion date is reached.""" + delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None) + self.scheduler.schedule_at(delete_at, msg['id'], self.delete_offensive_msg(msg)) + + async def reschedule_offensive_msg_deletion(self) -> None: + """Get all the pending message deletion from the API and reschedule them.""" + await self.bot.wait_until_ready() + response = await self.bot.api_client.get('bot/offensive-messages',) + + now = datetime.utcnow() + + for msg in response: + delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None) + + if delete_at < now: + await self.delete_offensive_msg(msg) + else: + self.schedule_msg_delete(msg) + + async def delete_offensive_msg(self, msg: Mapping[str, str]) -> None: + """Delete an offensive message, and then delete it from the db.""" + try: + channel = self.bot.get_channel(msg['channel_id']) + if channel: + msg_obj = await channel.fetch_message(msg['id']) + await msg_obj.delete() + except NotFound: + log.info( + f"Tried to delete message {msg['id']}, but the message can't be found " + f"(it has been probably already deleted)." + ) + except HTTPException as e: + log.warning(f"Failed to delete message {msg['id']}: status {e.status}") + + await self.bot.api_client.delete(f'bot/offensive-messages/{msg["id"]}') + log.info(f"Deleted the offensive message with id {msg['id']}.") + + +def setup(bot: Bot) -> None: + """Load the Filtering cog.""" + bot.add_cog(Filtering(bot)) diff --git a/bot/cogs/filters/security.py b/bot/cogs/filters/security.py new file mode 100644 index 000000000..c680c5e27 --- /dev/null +++ b/bot/cogs/filters/security.py @@ -0,0 +1,31 @@ +import logging + +from discord.ext.commands import Cog, Context, NoPrivateMessage + +from bot.bot import Bot + +log = logging.getLogger(__name__) + + +class Security(Cog): + """Security-related helpers.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.bot.check(self.check_not_bot) # Global commands check - no bots can run any commands at all + self.bot.check(self.check_on_guild) # Global commands check - commands can't be run in a DM + + def check_not_bot(self, ctx: Context) -> bool: + """Check if the context is a bot user.""" + return not ctx.author.bot + + def check_on_guild(self, ctx: Context) -> bool: + """Check if the context is in a guild.""" + if ctx.guild is None: + raise NoPrivateMessage("This command cannot be used in private messages.") + return True + + +def setup(bot: Bot) -> None: + """Load the Security cog.""" + bot.add_cog(Security(bot)) diff --git a/bot/cogs/filters/token_remover.py b/bot/cogs/filters/token_remover.py new file mode 100644 index 000000000..ef979f222 --- /dev/null +++ b/bot/cogs/filters/token_remover.py @@ -0,0 +1,182 @@ +import base64 +import binascii +import logging +import re +import typing as t + +from discord import Colour, Message, NotFound +from discord.ext.commands import Cog + +from bot import utils +from bot.bot import Bot +from bot.cogs.moderation import ModLog +from bot.constants import Channels, Colours, Event, Icons + +log = logging.getLogger(__name__) + +LOG_MESSAGE = ( + "Censored a seemingly valid token sent by {author} (`{author_id}`) in {channel}, " + "token was `{user_id}.{timestamp}.{hmac}`" +) +DELETION_MESSAGE_TEMPLATE = ( + "Hey {mention}! I noticed you posted a seemingly valid Discord API " + "token in your message and have removed your message. " + "This means that your token has been **compromised**. " + "Please change your token **immediately** at: " + "\n\n" + "Feel free to re-post it with the token removed. " + "If you believe this was a mistake, please let us know!" +) +DISCORD_EPOCH = 1_420_070_400 +TOKEN_EPOCH = 1_293_840_000 + +# Three parts delimited by dots: user ID, creation timestamp, HMAC. +# The HMAC isn't parsed further, but it's in the regex to ensure it at least exists in the string. +# Each part only matches base64 URL-safe characters. +# Padding has never been observed, but the padding character '=' is matched just in case. +TOKEN_RE = re.compile(r"([\w\-=]+)\.([\w\-=]+)\.([\w\-=]+)", re.ASCII) + + +class Token(t.NamedTuple): + """A Discord Bot token.""" + + user_id: str + timestamp: str + hmac: str + + +class TokenRemover(Cog): + """Scans messages for potential discord.py bot tokens and removes them.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @property + def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + @Cog.listener() + async def on_message(self, msg: Message) -> None: + """ + Check each message for a string that matches Discord's token pattern. + + See: https://discordapp.com/developers/docs/reference#snowflakes + """ + # Ignore DMs; can't delete messages in there anyway. + if not msg.guild or msg.author.bot: + return + + found_token = self.find_token_in_message(msg) + if found_token: + await self.take_action(msg, found_token) + + @Cog.listener() + async def on_message_edit(self, before: Message, after: Message) -> None: + """ + Check each edit for a string that matches Discord's token pattern. + + See: https://discordapp.com/developers/docs/reference#snowflakes + """ + await self.on_message(after) + + async def take_action(self, msg: Message, found_token: Token) -> None: + """Remove the `msg` containing the `found_token` and send a mod log message.""" + self.mod_log.ignore(Event.message_delete, msg.id) + + try: + await msg.delete() + except NotFound: + log.debug(f"Failed to remove token in message {msg.id}: message already deleted.") + return + + await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention)) + + log_message = self.format_log_message(msg, found_token) + log.debug(log_message) + + # Send pretty mod log embed to mod-alerts + await self.mod_log.send_log_message( + icon_url=Icons.token_removed, + colour=Colour(Colours.soft_red), + title="Token removed!", + text=log_message, + thumbnail=msg.author.avatar_url_as(static_format="png"), + channel_id=Channels.mod_alerts, + ) + + self.bot.stats.incr("tokens.removed_tokens") + + @staticmethod + def format_log_message(msg: Message, token: Token) -> str: + """Return the log message to send for `token` being censored in `msg`.""" + return LOG_MESSAGE.format( + author=msg.author, + author_id=msg.author.id, + channel=msg.channel.mention, + user_id=token.user_id, + timestamp=token.timestamp, + hmac='x' * len(token.hmac), + ) + + @classmethod + def find_token_in_message(cls, msg: Message) -> t.Optional[Token]: + """Return a seemingly valid token found in `msg` or `None` if no token is found.""" + # Use finditer rather than search to guard against method calls prematurely returning the + # token check (e.g. `message.channel.send` also matches our token pattern) + for match in TOKEN_RE.finditer(msg.content): + token = Token(*match.groups()) + if cls.is_valid_user_id(token.user_id) and cls.is_valid_timestamp(token.timestamp): + # Short-circuit on first match + return token + + # No matching substring + return + + @staticmethod + def is_valid_user_id(b64_content: str) -> bool: + """ + Check potential token to see if it contains a valid Discord user ID. + + See: https://discordapp.com/developers/docs/reference#snowflakes + """ + b64_content = utils.pad_base64(b64_content) + + try: + decoded_bytes = base64.urlsafe_b64decode(b64_content) + string = decoded_bytes.decode('utf-8') + + # isdigit on its own would match a lot of other Unicode characters, hence the isascii. + return string.isascii() and string.isdigit() + except (binascii.Error, ValueError): + return False + + @staticmethod + def is_valid_timestamp(b64_content: str) -> bool: + """ + Return True if `b64_content` decodes to a valid timestamp. + + If the timestamp is greater than the Discord epoch, it's probably valid. + See: https://i.imgur.com/7WdehGn.png + """ + b64_content = utils.pad_base64(b64_content) + + try: + decoded_bytes = base64.urlsafe_b64decode(b64_content) + timestamp = int.from_bytes(decoded_bytes, byteorder="big") + except (binascii.Error, ValueError) as e: + log.debug(f"Failed to decode token timestamp '{b64_content}': {e}") + return False + + # Seems like newer tokens don't need the epoch added, but add anyway since an upper bound + # is not checked. + if timestamp + TOKEN_EPOCH >= DISCORD_EPOCH: + return True + else: + log.debug(f"Invalid token timestamp '{b64_content}': smaller than Discord epoch") + return False + + +def setup(bot: Bot) -> None: + """Load the TokenRemover cog.""" + bot.add_cog(TokenRemover(bot)) diff --git a/bot/cogs/filters/webhook_remover.py b/bot/cogs/filters/webhook_remover.py new file mode 100644 index 000000000..5812da87c --- /dev/null +++ b/bot/cogs/filters/webhook_remover.py @@ -0,0 +1,84 @@ +import logging +import re + +from discord import Colour, Message, NotFound +from discord.ext.commands import Cog + +from bot.bot import Bot +from bot.cogs.moderation.modlog import ModLog +from bot.constants import Channels, Colours, Event, Icons + +WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\d+/)\S+/?", re.IGNORECASE) + +ALERT_MESSAGE_TEMPLATE = ( + "{user}, looks like you posted a Discord webhook URL. Therefore, your " + "message has been removed. Your webhook may have been **compromised** so " + "please re-create the webhook **immediately**. If you believe this was " + "mistake, please let us know." +) + +log = logging.getLogger(__name__) + + +class WebhookRemover(Cog): + """Scan messages to detect Discord webhooks links.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @property + def mod_log(self) -> ModLog: + """Get current instance of `ModLog`.""" + return self.bot.get_cog("ModLog") + + async def delete_and_respond(self, msg: Message, redacted_url: str) -> None: + """Delete `msg` and send a warning that it contained the Discord webhook `redacted_url`.""" + # Don't log this, due internal delete, not by user. Will make different entry. + self.mod_log.ignore(Event.message_delete, msg.id) + + try: + await msg.delete() + except NotFound: + log.debug(f"Failed to remove webhook in message {msg.id}: message already deleted.") + return + + await msg.channel.send(ALERT_MESSAGE_TEMPLATE.format(user=msg.author.mention)) + + message = ( + f"{msg.author} (`{msg.author.id}`) posted a Discord webhook URL " + f"to #{msg.channel}. Webhook URL was `{redacted_url}`" + ) + log.debug(message) + + # Send entry to moderation alerts. + await self.mod_log.send_log_message( + icon_url=Icons.token_removed, + colour=Colour(Colours.soft_red), + title="Discord webhook URL removed!", + text=message, + thumbnail=msg.author.avatar_url_as(static_format="png"), + channel_id=Channels.mod_alerts + ) + + self.bot.stats.incr("tokens.removed_webhooks") + + @Cog.listener() + async def on_message(self, msg: Message) -> None: + """Check if a Discord webhook URL is in `message`.""" + # Ignore DMs; can't delete messages in there anyway. + if not msg.guild or msg.author.bot: + return + + matches = WEBHOOK_URL_RE.search(msg.content) + if matches: + await self.delete_and_respond(msg, matches[1] + "xxx") + + @Cog.listener() + async def on_message_edit(self, before: Message, after: Message) -> None: + """Check if a Discord webhook URL is in the edited message `after`.""" + await self.on_message(after) + + +def setup(bot: Bot) -> None: + """Load `WebhookRemover` cog.""" + bot.add_cog(WebhookRemover(bot)) diff --git a/bot/cogs/help.py b/bot/cogs/help.py deleted file mode 100644 index 3d1d6fd10..000000000 --- a/bot/cogs/help.py +++ /dev/null @@ -1,375 +0,0 @@ -import itertools -import logging -from asyncio import TimeoutError -from collections import namedtuple -from contextlib import suppress -from typing import List, Union - -from discord import Colour, Embed, Member, Message, NotFound, Reaction, User -from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand -from fuzzywuzzy import fuzz, process -from fuzzywuzzy.utils import full_process - -from bot import constants -from bot.constants import Channels, Emojis, STAFF_ROLES -from bot.decorators import redirect_output -from bot.pagination import LinePaginator - -log = logging.getLogger(__name__) - -COMMANDS_PER_PAGE = 8 -DELETE_EMOJI = Emojis.trashcan -PREFIX = constants.Bot.prefix - -Category = namedtuple("Category", ["name", "description", "cogs"]) - - -async def help_cleanup(bot: Bot, author: Member, message: Message) -> None: - """ - Runs the cleanup for the help command. - - Adds the :trashcan: reaction that, when clicked, will delete the help message. - After a 300 second timeout, the reaction will be removed. - """ - def check(reaction: Reaction, user: User) -> bool: - """Checks the reaction is :trashcan:, the author is original author and messages are the same.""" - return str(reaction) == DELETE_EMOJI and user.id == author.id and reaction.message.id == message.id - - await message.add_reaction(DELETE_EMOJI) - - with suppress(NotFound): - try: - await bot.wait_for("reaction_add", check=check, timeout=300) - await message.delete() - except TimeoutError: - await message.remove_reaction(DELETE_EMOJI, bot.user) - - -class HelpQueryNotFound(ValueError): - """ - Raised when a HelpSession Query doesn't match a command or cog. - - Contains the custom attribute of ``possible_matches``. - - Instances of this object contain a dictionary of any command(s) that were close to matching the - query, where keys are the possible matched command names and values are the likeness match scores. - """ - - def __init__(self, arg: str, possible_matches: dict = None): - super().__init__(arg) - self.possible_matches = possible_matches - - -class CustomHelpCommand(HelpCommand): - """ - An interactive instance for the bot help command. - - Cogs can be grouped into custom categories. All cogs with the same category will be displayed - under a single category name in the help output. Custom categories are defined inside the cogs - as a class attribute named `category`. A description can also be specified with the attribute - `category_description`. If a description is not found in at least one cog, the default will be - the regular description (class docstring) of the first cog found in the category. - """ - - def __init__(self): - super().__init__(command_attrs={"help": "Shows help for bot commands"}) - - @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES) - async def command_callback(self, ctx: Context, *, command: str = None) -> None: - """Attempts to match the provided query with a valid command or cog.""" - # the only reason we need to tamper with this is because d.py does not support "categories", - # so we need to deal with them ourselves. - - bot = ctx.bot - - if command is None: - # quick and easy, send bot help if command is none - mapping = self.get_bot_mapping() - await self.send_bot_help(mapping) - return - - cog_matches = [] - description = None - for cog in bot.cogs.values(): - if hasattr(cog, "category") and cog.category == command: - cog_matches.append(cog) - if hasattr(cog, "category_description"): - description = cog.category_description - - if cog_matches: - category = Category(name=command, description=description, cogs=cog_matches) - await self.send_category_help(category) - return - - # it's either a cog, group, command or subcommand; let the parent class deal with it - await super().command_callback(ctx, command=command) - - async def get_all_help_choices(self) -> set: - """ - Get all the possible options for getting help in the bot. - - This will only display commands the author has permission to run. - - These include: - - Category names - - Cog names - - Group command names (and aliases) - - Command names (and aliases) - - Subcommand names (with parent group and aliases for subcommand, but not including aliases for group) - - Options and choices are case sensitive. - """ - # first get all commands including subcommands and full command name aliases - choices = set() - for command in await self.filter_commands(self.context.bot.walk_commands()): - # the the command or group name - choices.add(str(command)) - - if isinstance(command, Command): - # all aliases if it's just a command - choices.update(command.aliases) - else: - # otherwise we need to add the parent name in - choices.update(f"{command.full_parent_name} {alias}" for alias in command.aliases) - - # all cog names - choices.update(self.context.bot.cogs) - - # all category names - choices.update(cog.category for cog in self.context.bot.cogs.values() if hasattr(cog, "category")) - return choices - - async def command_not_found(self, string: str) -> "HelpQueryNotFound": - """ - Handles when a query does not match a valid command, group, cog or category. - - Will return an instance of the `HelpQueryNotFound` exception with the error message and possible matches. - """ - choices = await self.get_all_help_choices() - - # Run fuzzywuzzy's processor beforehand, and avoid matching if processed string is empty - # This avoids fuzzywuzzy from raising a warning on inputs with only non-alphanumeric characters - if (processed := full_process(string)): - result = process.extractBests(processed, choices, scorer=fuzz.ratio, score_cutoff=60, processor=None) - else: - result = [] - - return HelpQueryNotFound(f'Query "{string}" not found.', dict(result)) - - async def subcommand_not_found(self, command: Command, string: str) -> "HelpQueryNotFound": - """ - Redirects the error to `command_not_found`. - - `command_not_found` deals with searching and getting best choices for both commands and subcommands. - """ - return await self.command_not_found(f"{command.qualified_name} {string}") - - async def send_error_message(self, error: HelpQueryNotFound) -> None: - """Send the error message to the channel.""" - embed = Embed(colour=Colour.red(), title=str(error)) - - if getattr(error, "possible_matches", None): - matches = "\n".join(f"`{match}`" for match in error.possible_matches) - embed.description = f"**Did you mean:**\n{matches}" - - await self.context.send(embed=embed) - - async def command_formatting(self, command: Command) -> Embed: - """ - Takes a command and turns it into an embed. - - It will add an author, command signature + help, aliases and a note if the user can't run the command. - """ - embed = Embed() - embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) - - parent = command.full_parent_name - - name = str(command) if not parent else f"{parent} {command.name}" - command_details = f"**```{PREFIX}{name} {command.signature}```**\n" - - # show command aliases - aliases = ", ".join(f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in command.aliases) - if aliases: - command_details += f"**Can also use:** {aliases}\n\n" - - # check if the user is allowed to run this command - if not await command.can_run(self.context): - command_details += "***You cannot run this command.***\n\n" - - command_details += f"*{command.help or 'No details provided.'}*\n" - embed.description = command_details - - return embed - - async def send_command_help(self, command: Command) -> None: - """Send help for a single command.""" - embed = await self.command_formatting(command) - message = await self.context.send(embed=embed) - await help_cleanup(self.context.bot, self.context.author, message) - - @staticmethod - def get_commands_brief_details(commands_: List[Command], return_as_list: bool = False) -> Union[List[str], str]: - """ - Formats the prefix, command name and signature, and short doc for an iterable of commands. - - return_as_list is helpful for passing these command details into the paginator as a list of command details. - """ - details = [] - for command in commands_: - signature = f" {command.signature}" if command.signature else "" - details.append( - f"\n**`{PREFIX}{command.qualified_name}{signature}`**\n*{command.short_doc or 'No details provided'}*" - ) - if return_as_list: - return details - else: - return "".join(details) - - async def send_group_help(self, group: Group) -> None: - """Sends help for a group command.""" - subcommands = group.commands - - if len(subcommands) == 0: - # no subcommands, just treat it like a regular command - await self.send_command_help(group) - return - - # remove commands that the user can't run and are hidden, and sort by name - commands_ = await self.filter_commands(subcommands, sort=True) - - embed = await self.command_formatting(group) - - command_details = self.get_commands_brief_details(commands_) - if command_details: - embed.description += f"\n**Subcommands:**\n{command_details}" - - message = await self.context.send(embed=embed) - await help_cleanup(self.context.bot, self.context.author, message) - - async def send_cog_help(self, cog: Cog) -> None: - """Send help for a cog.""" - # sort commands by name, and remove any the user cant run or are hidden. - commands_ = await self.filter_commands(cog.get_commands(), sort=True) - - embed = Embed() - embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) - embed.description = f"**{cog.qualified_name}**\n*{cog.description}*" - - command_details = self.get_commands_brief_details(commands_) - if command_details: - embed.description += f"\n\n**Commands:**\n{command_details}" - - message = await self.context.send(embed=embed) - await help_cleanup(self.context.bot, self.context.author, message) - - @staticmethod - def _category_key(command: Command) -> str: - """ - Returns a cog name of a given command for use as a key for `sorted` and `groupby`. - - A zero width space is used as a prefix for results with no cogs to force them last in ordering. - """ - if command.cog: - with suppress(AttributeError): - if command.cog.category: - return f"**{command.cog.category}**" - return f"**{command.cog_name}**" - else: - return "**\u200bNo Category:**" - - async def send_category_help(self, category: Category) -> None: - """ - Sends help for a bot category. - - This sends a brief help for all commands in all cogs registered to the category. - """ - embed = Embed() - embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) - - all_commands = [] - for cog in category.cogs: - all_commands.extend(cog.get_commands()) - - filtered_commands = await self.filter_commands(all_commands, sort=True) - - command_detail_lines = self.get_commands_brief_details(filtered_commands, return_as_list=True) - description = f"**{category.name}**\n*{category.description}*" - - if command_detail_lines: - description += "\n\n**Commands:**" - - await LinePaginator.paginate( - command_detail_lines, - self.context, - embed, - prefix=description, - max_lines=COMMANDS_PER_PAGE, - max_size=2000, - ) - - async def send_bot_help(self, mapping: dict) -> None: - """Sends help for all bot commands and cogs.""" - bot = self.context.bot - - embed = Embed() - embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) - - filter_commands = await self.filter_commands(bot.commands, sort=True, key=self._category_key) - - cog_or_category_pages = [] - - for cog_or_category, _commands in itertools.groupby(filter_commands, key=self._category_key): - sorted_commands = sorted(_commands, key=lambda c: c.name) - - if len(sorted_commands) == 0: - continue - - command_detail_lines = self.get_commands_brief_details(sorted_commands, return_as_list=True) - - # Split cogs or categories which have too many commands to fit in one page. - # The length of commands is included for later use when aggregating into pages for the paginator. - for index in range(0, len(sorted_commands), COMMANDS_PER_PAGE): - truncated_lines = command_detail_lines[index:index + COMMANDS_PER_PAGE] - joined_lines = "".join(truncated_lines) - cog_or_category_pages.append((f"**{cog_or_category}**{joined_lines}", len(truncated_lines))) - - pages = [] - counter = 0 - page = "" - for page_details, length in cog_or_category_pages: - counter += length - if counter > COMMANDS_PER_PAGE: - # force a new page on paginator even if it falls short of the max pages - # since we still want to group categories/cogs. - counter = length - pages.append(page) - page = f"{page_details}\n\n" - else: - page += f"{page_details}\n\n" - - if page: - # add any remaining command help that didn't get added in the last iteration above. - pages.append(page) - - await LinePaginator.paginate(pages, self.context, embed=embed, max_lines=1, max_size=2000) - - -class Help(Cog): - """Custom Embed Pagination Help feature.""" - - def __init__(self, bot: Bot) -> None: - self.bot = bot - self.old_help_command = bot.help_command - bot.help_command = CustomHelpCommand() - bot.help_command.cog = self - - def cog_unload(self) -> None: - """Reset the help command when the cog is unloaded.""" - self.bot.help_command = self.old_help_command - - -def setup(bot: Bot) -> None: - """Load the Help cog.""" - bot.add_cog(Help(bot)) - log.info("Cog loaded: Help") diff --git a/bot/cogs/info/__init__.py b/bot/cogs/info/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bot/cogs/info/doc.py b/bot/cogs/info/doc.py new file mode 100644 index 000000000..204cffb37 --- /dev/null +++ b/bot/cogs/info/doc.py @@ -0,0 +1,511 @@ +import asyncio +import functools +import logging +import re +import textwrap +from collections import OrderedDict +from contextlib import suppress +from types import SimpleNamespace +from typing import Any, Callable, Optional, Tuple + +import discord +from bs4 import BeautifulSoup +from bs4.element import PageElement, Tag +from discord.errors import NotFound +from discord.ext import commands +from markdownify import MarkdownConverter +from requests import ConnectTimeout, ConnectionError, HTTPError +from sphinx.ext import intersphinx +from urllib3.exceptions import ProtocolError + +from bot.bot import Bot +from bot.constants import MODERATION_ROLES, RedirectOutput +from bot.converters import ValidPythonIdentifier, ValidURL +from bot.decorators import with_role +from bot.pagination import LinePaginator + + +log = logging.getLogger(__name__) +logging.getLogger('urllib3').setLevel(logging.WARNING) + +# Since Intersphinx is intended to be used with Sphinx, +# we need to mock its configuration. +SPHINX_MOCK_APP = SimpleNamespace( + config=SimpleNamespace( + intersphinx_timeout=3, + tls_verify=True, + user_agent="python3:python-discord/bot:1.0.0" + ) +) + +NO_OVERRIDE_GROUPS = ( + "2to3fixer", + "token", + "label", + "pdbcommand", + "term", +) +NO_OVERRIDE_PACKAGES = ( + "python", +) + +SEARCH_END_TAG_ATTRS = ( + "data", + "function", + "class", + "exception", + "seealso", + "section", + "rubric", + "sphinxsidebar", +) +UNWANTED_SIGNATURE_SYMBOLS_RE = re.compile(r"\[source]|\\\\|¶") +WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)") + +FAILED_REQUEST_RETRY_AMOUNT = 3 +NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay + + +def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable: + """ + LRU cache implementation for coroutines. + + Once the cache exceeds the maximum size, keys are deleted in FIFO order. + + An offset may be optionally provided to be applied to the coroutine's arguments when creating the cache key. + """ + # Assign the cache to the function itself so we can clear it from outside. + async_cache.cache = OrderedDict() + + def decorator(function: Callable) -> Callable: + """Define the async_cache decorator.""" + @functools.wraps(function) + async def wrapper(*args) -> Any: + """Decorator wrapper for the caching logic.""" + key = ':'.join(args[arg_offset:]) + + value = async_cache.cache.get(key) + if value is None: + if len(async_cache.cache) > max_size: + async_cache.cache.popitem(last=False) + + async_cache.cache[key] = await function(*args) + return async_cache.cache[key] + return wrapper + return decorator + + +class DocMarkdownConverter(MarkdownConverter): + """Subclass markdownify's MarkdownCoverter to provide custom conversion methods.""" + + def convert_code(self, el: PageElement, text: str) -> str: + """Undo `markdownify`s underscore escaping.""" + return f"`{text}`".replace('\\', '') + + def convert_pre(self, el: PageElement, text: str) -> str: + """Wrap any codeblocks in `py` for syntax highlighting.""" + code = ''.join(el.strings) + return f"```py\n{code}```" + + +def markdownify(html: str) -> DocMarkdownConverter: + """Create a DocMarkdownConverter object from the input html.""" + return DocMarkdownConverter(bullets='•').convert(html) + + +class InventoryURL(commands.Converter): + """ + Represents an Intersphinx inventory URL. + + This converter checks whether intersphinx accepts the given inventory URL, and raises + `BadArgument` if that is not the case. + + Otherwise, it simply passes through the given URL. + """ + + @staticmethod + async def convert(ctx: commands.Context, url: str) -> str: + """Convert url to Intersphinx inventory URL.""" + try: + intersphinx.fetch_inventory(SPHINX_MOCK_APP, '', url) + except AttributeError: + raise commands.BadArgument(f"Failed to fetch Intersphinx inventory from URL `{url}`.") + except ConnectionError: + if url.startswith('https'): + raise commands.BadArgument( + f"Cannot establish a connection to `{url}`. Does it support HTTPS?" + ) + raise commands.BadArgument(f"Cannot connect to host with URL `{url}`.") + except ValueError: + raise commands.BadArgument( + f"Failed to read Intersphinx inventory from URL `{url}`. " + "Are you sure that it's a valid inventory file?" + ) + return url + + +class Doc(commands.Cog): + """A set of commands for querying & displaying documentation.""" + + def __init__(self, bot: Bot): + self.base_urls = {} + self.bot = bot + self.inventories = {} + self.renamed_symbols = set() + + self.bot.loop.create_task(self.init_refresh_inventory()) + + async def init_refresh_inventory(self) -> None: + """Refresh documentation inventory on cog initialization.""" + await self.bot.wait_until_guild_available() + await self.refresh_inventory() + + async def update_single( + self, package_name: str, base_url: str, inventory_url: str + ) -> None: + """ + Rebuild the inventory for a single package. + + Where: + * `package_name` is the package name to use, appears in the log + * `base_url` is the root documentation URL for the specified package, used to build + absolute paths that link to specific symbols + * `inventory_url` is the absolute URL to the intersphinx inventory, fetched by running + `intersphinx.fetch_inventory` in an executor on the bot's event loop + """ + self.base_urls[package_name] = base_url + + package = await self._fetch_inventory(inventory_url) + if not package: + return None + + for group, value in package.items(): + for symbol, (package_name, _version, relative_doc_url, _) in value.items(): + absolute_doc_url = base_url + relative_doc_url + + if symbol in self.inventories: + group_name = group.split(":")[1] + symbol_base_url = self.inventories[symbol].split("/", 3)[2] + if ( + group_name in NO_OVERRIDE_GROUPS + or any(package in symbol_base_url for package in NO_OVERRIDE_PACKAGES) + ): + + symbol = f"{group_name}.{symbol}" + # If renamed `symbol` already exists, add library name in front to differentiate between them. + if symbol in self.renamed_symbols: + # Split `package_name` because of packages like Pillow that have spaces in them. + symbol = f"{package_name.split()[0]}.{symbol}" + + self.inventories[symbol] = absolute_doc_url + self.renamed_symbols.add(symbol) + continue + + self.inventories[symbol] = absolute_doc_url + + log.trace(f"Fetched inventory for {package_name}.") + + async def refresh_inventory(self) -> None: + """Refresh internal documentation inventory.""" + log.debug("Refreshing documentation inventory...") + + # Clear the old base URLS and inventories to ensure + # that we start from a fresh local dataset. + # Also, reset the cache used for fetching documentation. + self.base_urls.clear() + self.inventories.clear() + self.renamed_symbols.clear() + async_cache.cache = OrderedDict() + + # Run all coroutines concurrently - since each of them performs a HTTP + # request, this speeds up fetching the inventory data heavily. + coros = [ + self.update_single( + package["package"], package["base_url"], package["inventory_url"] + ) for package in await self.bot.api_client.get('bot/documentation-links') + ] + await asyncio.gather(*coros) + + async def get_symbol_html(self, symbol: str) -> Optional[Tuple[list, str]]: + """ + Given a Python symbol, return its signature and description. + + The first tuple element is the signature of the given symbol as a markup-free string, and + the second tuple element is the description of the given symbol with HTML markup included. + + If the given symbol is a module, returns a tuple `(None, str)` + else if the symbol could not be found, returns `None`. + """ + url = self.inventories.get(symbol) + if url is None: + return None + + async with self.bot.http_session.get(url) as response: + html = await response.text(encoding='utf-8') + + # Find the signature header and parse the relevant parts. + symbol_id = url.split('#')[-1] + soup = BeautifulSoup(html, 'lxml') + symbol_heading = soup.find(id=symbol_id) + search_html = str(soup) + + if symbol_heading is None: + return None + + if symbol_id == f"module-{symbol}": + # Get page content from the module headerlink to the + # first tag that has its class in `SEARCH_END_TAG_ATTRS` + start_tag = symbol_heading.find("a", attrs={"class": "headerlink"}) + if start_tag is None: + return [], "" + + end_tag = start_tag.find_next(self._match_end_tag) + if end_tag is None: + return [], "" + + description_start_index = search_html.find(str(start_tag.parent)) + len(str(start_tag.parent)) + description_end_index = search_html.find(str(end_tag)) + description = search_html[description_start_index:description_end_index] + signatures = None + + else: + signatures = [] + description = str(symbol_heading.find_next_sibling("dd")) + description_pos = search_html.find(description) + # Get text of up to 3 signatures, remove unwanted symbols + for element in [symbol_heading] + symbol_heading.find_next_siblings("dt", limit=2): + signature = UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element.text) + if signature and search_html.find(str(element)) < description_pos: + signatures.append(signature) + + return signatures, description.replace('¶', '') + + @async_cache(arg_offset=1) + async def get_symbol_embed(self, symbol: str) -> Optional[discord.Embed]: + """ + Attempt to scrape and fetch the data for the given `symbol`, and build an embed from its contents. + + If the symbol is known, an Embed with documentation about it is returned. + """ + scraped_html = await self.get_symbol_html(symbol) + if scraped_html is None: + return None + + signatures = scraped_html[0] + permalink = self.inventories[symbol] + description = markdownify(scraped_html[1]) + + # Truncate the description of the embed to the last occurrence + # of a double newline (interpreted as a paragraph) before index 1000. + if len(description) > 1000: + shortened = description[:1000] + description_cutoff = shortened.rfind('\n\n', 100) + if description_cutoff == -1: + # Search the shortened version for cutoff points in decreasing desirability, + # cutoff at 1000 if none are found. + for string in (". ", ", ", ",", " "): + description_cutoff = shortened.rfind(string) + if description_cutoff != -1: + break + else: + description_cutoff = 1000 + description = description[:description_cutoff] + + # If there is an incomplete code block, cut it out + if description.count("```") % 2: + codeblock_start = description.rfind('```py') + description = description[:codeblock_start].rstrip() + description += f"... [read more]({permalink})" + + description = WHITESPACE_AFTER_NEWLINES_RE.sub('', description) + if signatures is None: + # If symbol is a module, don't show signature. + embed_description = description + + elif not signatures: + # It's some "meta-page", for example: + # https://docs.djangoproject.com/en/dev/ref/views/#module-django.views + embed_description = "This appears to be a generic page not tied to a specific symbol." + + else: + embed_description = "".join(f"```py\n{textwrap.shorten(signature, 500)}```" for signature in signatures) + embed_description += f"\n{description}" + + embed = discord.Embed( + title=f'`{symbol}`', + url=permalink, + description=embed_description + ) + # Show all symbols with the same name that were renamed in the footer. + embed.set_footer( + text=", ".join(renamed for renamed in self.renamed_symbols - {symbol} if renamed.endswith(f".{symbol}")) + ) + return embed + + @commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True) + async def docs_group(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None: + """Lookup documentation for Python symbols.""" + await ctx.invoke(self.get_command, symbol) + + @docs_group.command(name='get', aliases=('g',)) + async def get_command(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None: + """ + Return a documentation embed for a given symbol. + + If no symbol is given, return a list of all available inventories. + + Examples: + !docs + !docs aiohttp + !docs aiohttp.ClientSession + !docs get aiohttp.ClientSession + """ + if symbol is None: + inventory_embed = discord.Embed( + title=f"All inventories (`{len(self.base_urls)}` total)", + colour=discord.Colour.blue() + ) + + lines = sorted(f"• [`{name}`]({url})" for name, url in self.base_urls.items()) + if self.base_urls: + await LinePaginator.paginate(lines, ctx, inventory_embed, max_size=400, empty=False) + + else: + inventory_embed.description = "Hmmm, seems like there's nothing here yet." + await ctx.send(embed=inventory_embed) + + else: + # Fetching documentation for a symbol (at least for the first time, since + # caching is used) takes quite some time, so let's send typing to indicate + # that we got the command, but are still working on it. + async with ctx.typing(): + doc_embed = await self.get_symbol_embed(symbol) + + if doc_embed is None: + error_embed = discord.Embed( + description=f"Sorry, I could not find any documentation for `{symbol}`.", + colour=discord.Colour.red() + ) + error_message = await ctx.send(embed=error_embed) + with suppress(NotFound): + await error_message.delete(delay=NOT_FOUND_DELETE_DELAY) + await ctx.message.delete(delay=NOT_FOUND_DELETE_DELAY) + else: + await ctx.send(embed=doc_embed) + + @docs_group.command(name='set', aliases=('s',)) + @with_role(*MODERATION_ROLES) + async def set_command( + self, ctx: commands.Context, package_name: ValidPythonIdentifier, + base_url: ValidURL, inventory_url: InventoryURL + ) -> None: + """ + Adds a new documentation metadata object to the site's database. + + The database will update the object, should an existing item with the specified `package_name` already exist. + + Example: + !docs set \ + python \ + https://docs.python.org/3/ \ + https://docs.python.org/3/objects.inv + """ + body = { + 'package': package_name, + 'base_url': base_url, + 'inventory_url': inventory_url + } + await self.bot.api_client.post('bot/documentation-links', json=body) + + log.info( + f"User @{ctx.author} ({ctx.author.id}) added a new documentation package:\n" + f"Package name: {package_name}\n" + f"Base url: {base_url}\n" + f"Inventory URL: {inventory_url}" + ) + + # Rebuilding the inventory can take some time, so lets send out a + # typing event to show that the Bot is still working. + async with ctx.typing(): + await self.refresh_inventory() + await ctx.send(f"Added package `{package_name}` to database and refreshed inventory.") + + @docs_group.command(name='delete', aliases=('remove', 'rm', 'd')) + @with_role(*MODERATION_ROLES) + async def delete_command(self, ctx: commands.Context, package_name: ValidPythonIdentifier) -> None: + """ + Removes the specified package from the database. + + Examples: + !docs delete aiohttp + """ + await self.bot.api_client.delete(f'bot/documentation-links/{package_name}') + + async with ctx.typing(): + # Rebuild the inventory to ensure that everything + # that was from this package is properly deleted. + await self.refresh_inventory() + await ctx.send(f"Successfully deleted `{package_name}` and refreshed inventory.") + + @docs_group.command(name="refresh", aliases=("rfsh", "r")) + @with_role(*MODERATION_ROLES) + async def refresh_command(self, ctx: commands.Context) -> None: + """Refresh inventories and send differences to channel.""" + old_inventories = set(self.base_urls) + with ctx.typing(): + await self.refresh_inventory() + # Get differences of added and removed inventories + added = ', '.join(inv for inv in self.base_urls if inv not in old_inventories) + if added: + added = f"+ {added}" + + removed = ', '.join(inv for inv in old_inventories if inv not in self.base_urls) + if removed: + removed = f"- {removed}" + + embed = discord.Embed( + title="Inventories refreshed", + description=f"```diff\n{added}\n{removed}```" if added or removed else "" + ) + await ctx.send(embed=embed) + + async def _fetch_inventory(self, inventory_url: str) -> Optional[dict]: + """Get and return inventory from `inventory_url`. If fetching fails, return None.""" + fetch_func = functools.partial(intersphinx.fetch_inventory, SPHINX_MOCK_APP, '', inventory_url) + for retry in range(1, FAILED_REQUEST_RETRY_AMOUNT+1): + try: + package = await self.bot.loop.run_in_executor(None, fetch_func) + except ConnectTimeout: + log.error( + f"Fetching of inventory {inventory_url} timed out," + f" trying again. ({retry}/{FAILED_REQUEST_RETRY_AMOUNT})" + ) + except ProtocolError: + log.error( + f"Connection lost while fetching inventory {inventory_url}," + f" trying again. ({retry}/{FAILED_REQUEST_RETRY_AMOUNT})" + ) + except HTTPError as e: + log.error(f"Fetching of inventory {inventory_url} failed with status code {e.response.status_code}.") + return None + except ConnectionError: + log.error(f"Couldn't establish connection to inventory {inventory_url}.") + return None + else: + return package + log.error(f"Fetching of inventory {inventory_url} failed.") + return None + + @staticmethod + def _match_end_tag(tag: Tag) -> bool: + """Matches `tag` if its class value is in `SEARCH_END_TAG_ATTRS` or the tag is table.""" + for attr in SEARCH_END_TAG_ATTRS: + if attr in tag.get("class", ()): + return True + + return tag.name == "table" + + +def setup(bot: Bot) -> None: + """Load the Doc cog.""" + bot.add_cog(Doc(bot)) diff --git a/bot/cogs/info/help.py b/bot/cogs/info/help.py new file mode 100644 index 000000000..3d1d6fd10 --- /dev/null +++ b/bot/cogs/info/help.py @@ -0,0 +1,375 @@ +import itertools +import logging +from asyncio import TimeoutError +from collections import namedtuple +from contextlib import suppress +from typing import List, Union + +from discord import Colour, Embed, Member, Message, NotFound, Reaction, User +from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand +from fuzzywuzzy import fuzz, process +from fuzzywuzzy.utils import full_process + +from bot import constants +from bot.constants import Channels, Emojis, STAFF_ROLES +from bot.decorators import redirect_output +from bot.pagination import LinePaginator + +log = logging.getLogger(__name__) + +COMMANDS_PER_PAGE = 8 +DELETE_EMOJI = Emojis.trashcan +PREFIX = constants.Bot.prefix + +Category = namedtuple("Category", ["name", "description", "cogs"]) + + +async def help_cleanup(bot: Bot, author: Member, message: Message) -> None: + """ + Runs the cleanup for the help command. + + Adds the :trashcan: reaction that, when clicked, will delete the help message. + After a 300 second timeout, the reaction will be removed. + """ + def check(reaction: Reaction, user: User) -> bool: + """Checks the reaction is :trashcan:, the author is original author and messages are the same.""" + return str(reaction) == DELETE_EMOJI and user.id == author.id and reaction.message.id == message.id + + await message.add_reaction(DELETE_EMOJI) + + with suppress(NotFound): + try: + await bot.wait_for("reaction_add", check=check, timeout=300) + await message.delete() + except TimeoutError: + await message.remove_reaction(DELETE_EMOJI, bot.user) + + +class HelpQueryNotFound(ValueError): + """ + Raised when a HelpSession Query doesn't match a command or cog. + + Contains the custom attribute of ``possible_matches``. + + Instances of this object contain a dictionary of any command(s) that were close to matching the + query, where keys are the possible matched command names and values are the likeness match scores. + """ + + def __init__(self, arg: str, possible_matches: dict = None): + super().__init__(arg) + self.possible_matches = possible_matches + + +class CustomHelpCommand(HelpCommand): + """ + An interactive instance for the bot help command. + + Cogs can be grouped into custom categories. All cogs with the same category will be displayed + under a single category name in the help output. Custom categories are defined inside the cogs + as a class attribute named `category`. A description can also be specified with the attribute + `category_description`. If a description is not found in at least one cog, the default will be + the regular description (class docstring) of the first cog found in the category. + """ + + def __init__(self): + super().__init__(command_attrs={"help": "Shows help for bot commands"}) + + @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES) + async def command_callback(self, ctx: Context, *, command: str = None) -> None: + """Attempts to match the provided query with a valid command or cog.""" + # the only reason we need to tamper with this is because d.py does not support "categories", + # so we need to deal with them ourselves. + + bot = ctx.bot + + if command is None: + # quick and easy, send bot help if command is none + mapping = self.get_bot_mapping() + await self.send_bot_help(mapping) + return + + cog_matches = [] + description = None + for cog in bot.cogs.values(): + if hasattr(cog, "category") and cog.category == command: + cog_matches.append(cog) + if hasattr(cog, "category_description"): + description = cog.category_description + + if cog_matches: + category = Category(name=command, description=description, cogs=cog_matches) + await self.send_category_help(category) + return + + # it's either a cog, group, command or subcommand; let the parent class deal with it + await super().command_callback(ctx, command=command) + + async def get_all_help_choices(self) -> set: + """ + Get all the possible options for getting help in the bot. + + This will only display commands the author has permission to run. + + These include: + - Category names + - Cog names + - Group command names (and aliases) + - Command names (and aliases) + - Subcommand names (with parent group and aliases for subcommand, but not including aliases for group) + + Options and choices are case sensitive. + """ + # first get all commands including subcommands and full command name aliases + choices = set() + for command in await self.filter_commands(self.context.bot.walk_commands()): + # the the command or group name + choices.add(str(command)) + + if isinstance(command, Command): + # all aliases if it's just a command + choices.update(command.aliases) + else: + # otherwise we need to add the parent name in + choices.update(f"{command.full_parent_name} {alias}" for alias in command.aliases) + + # all cog names + choices.update(self.context.bot.cogs) + + # all category names + choices.update(cog.category for cog in self.context.bot.cogs.values() if hasattr(cog, "category")) + return choices + + async def command_not_found(self, string: str) -> "HelpQueryNotFound": + """ + Handles when a query does not match a valid command, group, cog or category. + + Will return an instance of the `HelpQueryNotFound` exception with the error message and possible matches. + """ + choices = await self.get_all_help_choices() + + # Run fuzzywuzzy's processor beforehand, and avoid matching if processed string is empty + # This avoids fuzzywuzzy from raising a warning on inputs with only non-alphanumeric characters + if (processed := full_process(string)): + result = process.extractBests(processed, choices, scorer=fuzz.ratio, score_cutoff=60, processor=None) + else: + result = [] + + return HelpQueryNotFound(f'Query "{string}" not found.', dict(result)) + + async def subcommand_not_found(self, command: Command, string: str) -> "HelpQueryNotFound": + """ + Redirects the error to `command_not_found`. + + `command_not_found` deals with searching and getting best choices for both commands and subcommands. + """ + return await self.command_not_found(f"{command.qualified_name} {string}") + + async def send_error_message(self, error: HelpQueryNotFound) -> None: + """Send the error message to the channel.""" + embed = Embed(colour=Colour.red(), title=str(error)) + + if getattr(error, "possible_matches", None): + matches = "\n".join(f"`{match}`" for match in error.possible_matches) + embed.description = f"**Did you mean:**\n{matches}" + + await self.context.send(embed=embed) + + async def command_formatting(self, command: Command) -> Embed: + """ + Takes a command and turns it into an embed. + + It will add an author, command signature + help, aliases and a note if the user can't run the command. + """ + embed = Embed() + embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) + + parent = command.full_parent_name + + name = str(command) if not parent else f"{parent} {command.name}" + command_details = f"**```{PREFIX}{name} {command.signature}```**\n" + + # show command aliases + aliases = ", ".join(f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in command.aliases) + if aliases: + command_details += f"**Can also use:** {aliases}\n\n" + + # check if the user is allowed to run this command + if not await command.can_run(self.context): + command_details += "***You cannot run this command.***\n\n" + + command_details += f"*{command.help or 'No details provided.'}*\n" + embed.description = command_details + + return embed + + async def send_command_help(self, command: Command) -> None: + """Send help for a single command.""" + embed = await self.command_formatting(command) + message = await self.context.send(embed=embed) + await help_cleanup(self.context.bot, self.context.author, message) + + @staticmethod + def get_commands_brief_details(commands_: List[Command], return_as_list: bool = False) -> Union[List[str], str]: + """ + Formats the prefix, command name and signature, and short doc for an iterable of commands. + + return_as_list is helpful for passing these command details into the paginator as a list of command details. + """ + details = [] + for command in commands_: + signature = f" {command.signature}" if command.signature else "" + details.append( + f"\n**`{PREFIX}{command.qualified_name}{signature}`**\n*{command.short_doc or 'No details provided'}*" + ) + if return_as_list: + return details + else: + return "".join(details) + + async def send_group_help(self, group: Group) -> None: + """Sends help for a group command.""" + subcommands = group.commands + + if len(subcommands) == 0: + # no subcommands, just treat it like a regular command + await self.send_command_help(group) + return + + # remove commands that the user can't run and are hidden, and sort by name + commands_ = await self.filter_commands(subcommands, sort=True) + + embed = await self.command_formatting(group) + + command_details = self.get_commands_brief_details(commands_) + if command_details: + embed.description += f"\n**Subcommands:**\n{command_details}" + + message = await self.context.send(embed=embed) + await help_cleanup(self.context.bot, self.context.author, message) + + async def send_cog_help(self, cog: Cog) -> None: + """Send help for a cog.""" + # sort commands by name, and remove any the user cant run or are hidden. + commands_ = await self.filter_commands(cog.get_commands(), sort=True) + + embed = Embed() + embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) + embed.description = f"**{cog.qualified_name}**\n*{cog.description}*" + + command_details = self.get_commands_brief_details(commands_) + if command_details: + embed.description += f"\n\n**Commands:**\n{command_details}" + + message = await self.context.send(embed=embed) + await help_cleanup(self.context.bot, self.context.author, message) + + @staticmethod + def _category_key(command: Command) -> str: + """ + Returns a cog name of a given command for use as a key for `sorted` and `groupby`. + + A zero width space is used as a prefix for results with no cogs to force them last in ordering. + """ + if command.cog: + with suppress(AttributeError): + if command.cog.category: + return f"**{command.cog.category}**" + return f"**{command.cog_name}**" + else: + return "**\u200bNo Category:**" + + async def send_category_help(self, category: Category) -> None: + """ + Sends help for a bot category. + + This sends a brief help for all commands in all cogs registered to the category. + """ + embed = Embed() + embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) + + all_commands = [] + for cog in category.cogs: + all_commands.extend(cog.get_commands()) + + filtered_commands = await self.filter_commands(all_commands, sort=True) + + command_detail_lines = self.get_commands_brief_details(filtered_commands, return_as_list=True) + description = f"**{category.name}**\n*{category.description}*" + + if command_detail_lines: + description += "\n\n**Commands:**" + + await LinePaginator.paginate( + command_detail_lines, + self.context, + embed, + prefix=description, + max_lines=COMMANDS_PER_PAGE, + max_size=2000, + ) + + async def send_bot_help(self, mapping: dict) -> None: + """Sends help for all bot commands and cogs.""" + bot = self.context.bot + + embed = Embed() + embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) + + filter_commands = await self.filter_commands(bot.commands, sort=True, key=self._category_key) + + cog_or_category_pages = [] + + for cog_or_category, _commands in itertools.groupby(filter_commands, key=self._category_key): + sorted_commands = sorted(_commands, key=lambda c: c.name) + + if len(sorted_commands) == 0: + continue + + command_detail_lines = self.get_commands_brief_details(sorted_commands, return_as_list=True) + + # Split cogs or categories which have too many commands to fit in one page. + # The length of commands is included for later use when aggregating into pages for the paginator. + for index in range(0, len(sorted_commands), COMMANDS_PER_PAGE): + truncated_lines = command_detail_lines[index:index + COMMANDS_PER_PAGE] + joined_lines = "".join(truncated_lines) + cog_or_category_pages.append((f"**{cog_or_category}**{joined_lines}", len(truncated_lines))) + + pages = [] + counter = 0 + page = "" + for page_details, length in cog_or_category_pages: + counter += length + if counter > COMMANDS_PER_PAGE: + # force a new page on paginator even if it falls short of the max pages + # since we still want to group categories/cogs. + counter = length + pages.append(page) + page = f"{page_details}\n\n" + else: + page += f"{page_details}\n\n" + + if page: + # add any remaining command help that didn't get added in the last iteration above. + pages.append(page) + + await LinePaginator.paginate(pages, self.context, embed=embed, max_lines=1, max_size=2000) + + +class Help(Cog): + """Custom Embed Pagination Help feature.""" + + def __init__(self, bot: Bot) -> None: + self.bot = bot + self.old_help_command = bot.help_command + bot.help_command = CustomHelpCommand() + bot.help_command.cog = self + + def cog_unload(self) -> None: + """Reset the help command when the cog is unloaded.""" + self.bot.help_command = self.old_help_command + + +def setup(bot: Bot) -> None: + """Load the Help cog.""" + bot.add_cog(Help(bot)) + log.info("Cog loaded: Help") diff --git a/bot/cogs/info/information.py b/bot/cogs/info/information.py new file mode 100644 index 000000000..8982196d1 --- /dev/null +++ b/bot/cogs/info/information.py @@ -0,0 +1,422 @@ +import colorsys +import logging +import pprint +import textwrap +from collections import Counter, defaultdict +from string import Template +from typing import Any, Mapping, Optional, Union + +from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, utils +from discord.abc import GuildChannel +from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group +from discord.utils import escape_markdown + +from bot import constants +from bot.bot import Bot +from bot.decorators import in_whitelist, with_role +from bot.pagination import LinePaginator +from bot.utils.checks import InWhitelistCheckFailure, cooldown_with_role_bypass, with_role_check +from bot.utils.time import time_since + +log = logging.getLogger(__name__) + + +class Information(Cog): + """A cog with commands for generating embeds with server info, such as server stats and user info.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @staticmethod + def role_can_read(channel: GuildChannel, role: Role) -> bool: + """Return True if `role` can read messages in `channel`.""" + overwrites = channel.overwrites_for(role) + return overwrites.read_messages is True + + def get_staff_channel_count(self, guild: Guild) -> int: + """ + Get the number of channels that are staff-only. + + We need to know two things about a channel: + - Does the @everyone role have explicit read deny permissions? + - Do staff roles have explicit read allow permissions? + + If the answer to both of these questions is yes, it's a staff channel. + """ + channel_ids = set() + for channel in guild.channels: + if channel.type is ChannelType.category: + continue + + everyone_can_read = self.role_can_read(channel, guild.default_role) + + for role in constants.STAFF_ROLES: + role_can_read = self.role_can_read(channel, guild.get_role(role)) + if role_can_read and not everyone_can_read: + channel_ids.add(channel.id) + break + + return len(channel_ids) + + @staticmethod + def get_channel_type_counts(guild: Guild) -> str: + """Return the total amounts of the various types of channels in `guild`.""" + channel_counter = Counter(c.type for c in guild.channels) + channel_type_list = [] + for channel, count in channel_counter.items(): + channel_type = str(channel).title() + channel_type_list.append(f"{channel_type} channels: {count}") + + channel_type_list = sorted(channel_type_list) + return "\n".join(channel_type_list) + + @with_role(*constants.MODERATION_ROLES) + @command(name="roles") + async def roles_info(self, ctx: Context) -> None: + """Returns a list of all roles and their corresponding IDs.""" + # Sort the roles alphabetically and remove the @everyone role + roles = sorted(ctx.guild.roles[1:], key=lambda role: role.name) + + # Build a list + role_list = [] + for role in roles: + role_list.append(f"`{role.id}` - {role.mention}") + + # Build an embed + embed = Embed( + title=f"Role information (Total {len(roles)} role{'s' * (len(role_list) > 1)})", + colour=Colour.blurple() + ) + + await LinePaginator.paginate(role_list, ctx, embed, empty=False) + + @with_role(*constants.MODERATION_ROLES) + @command(name="role") + async def role_info(self, ctx: Context, *roles: Union[Role, str]) -> None: + """ + Return information on a role or list of roles. + + To specify multiple roles just add to the arguments, delimit roles with spaces in them using quotation marks. + """ + parsed_roles = [] + failed_roles = [] + + for role_name in roles: + if isinstance(role_name, Role): + # Role conversion has already succeeded + parsed_roles.append(role_name) + continue + + role = utils.find(lambda r: r.name.lower() == role_name.lower(), ctx.guild.roles) + + if not role: + failed_roles.append(role_name) + continue + + parsed_roles.append(role) + + if failed_roles: + await ctx.send(f":x: Could not retrieve the following roles: {', '.join(failed_roles)}") + + for role in parsed_roles: + h, s, v = colorsys.rgb_to_hsv(*role.colour.to_rgb()) + + embed = Embed( + title=f"{role.name} info", + colour=role.colour, + ) + embed.add_field(name="ID", value=role.id, inline=True) + embed.add_field(name="Colour (RGB)", value=f"#{role.colour.value:0>6x}", inline=True) + embed.add_field(name="Colour (HSV)", value=f"{h:.2f} {s:.2f} {v}", inline=True) + embed.add_field(name="Member count", value=len(role.members), inline=True) + embed.add_field(name="Position", value=role.position) + embed.add_field(name="Permission code", value=role.permissions.value, inline=True) + + await ctx.send(embed=embed) + + @command(name="server", aliases=["server_info", "guild", "guild_info"]) + async def server_info(self, ctx: Context) -> None: + """Returns an embed full of server information.""" + created = time_since(ctx.guild.created_at, precision="days") + features = ", ".join(ctx.guild.features) + region = ctx.guild.region + + roles = len(ctx.guild.roles) + member_count = ctx.guild.member_count + channel_counts = self.get_channel_type_counts(ctx.guild) + + # How many of each user status? + statuses = Counter(member.status for member in ctx.guild.members) + embed = Embed(colour=Colour.blurple()) + + # How many staff members and staff channels do we have? + staff_member_count = len(ctx.guild.get_role(constants.Roles.helpers).members) + staff_channel_count = self.get_staff_channel_count(ctx.guild) + + # Because channel_counts lacks leading whitespace, it breaks the dedent if it's inserted directly by the + # f-string. While this is correctly formated by Discord, it makes unit testing difficult. To keep the formatting + # without joining a tuple of strings we can use a Template string to insert the already-formatted channel_counts + # after the dedent is made. + embed.description = Template( + textwrap.dedent(f""" + **Server information** + Created: {created} + Voice region: {region} + Features: {features} + + **Channel counts** + $channel_counts + Staff channels: {staff_channel_count} + + **Member counts** + Members: {member_count:,} + Staff members: {staff_member_count} + Roles: {roles} + + **Member statuses** + {constants.Emojis.status_online} {statuses[Status.online]:,} + {constants.Emojis.status_idle} {statuses[Status.idle]:,} + {constants.Emojis.status_dnd} {statuses[Status.dnd]:,} + {constants.Emojis.status_offline} {statuses[Status.offline]:,} + """) + ).substitute({"channel_counts": channel_counts}) + embed.set_thumbnail(url=ctx.guild.icon_url) + + await ctx.send(embed=embed) + + @command(name="user", aliases=["user_info", "member", "member_info"]) + async def user_info(self, ctx: Context, user: Member = None) -> None: + """Returns info about a user.""" + if user is None: + user = ctx.author + + # Do a role check if this is being executed on someone other than the caller + elif user != ctx.author and not with_role_check(ctx, *constants.MODERATION_ROLES): + await ctx.send("You may not use this command on users other than yourself.") + return + + # Non-staff may only do this in #bot-commands + if not with_role_check(ctx, *constants.STAFF_ROLES): + if not ctx.channel.id == constants.Channels.bot_commands: + raise InWhitelistCheckFailure(constants.Channels.bot_commands) + + embed = await self.create_user_embed(ctx, user) + + await ctx.send(embed=embed) + + async def create_user_embed(self, ctx: Context, user: Member) -> Embed: + """Creates an embed containing information on the `user`.""" + created = time_since(user.created_at, max_units=3) + + # Custom status + custom_status = '' + for activity in user.activities: + # Check activity.state for None value if user has a custom status set + # This guards against a custom status with an emoji but no text, which will cause + # escape_markdown to raise an exception + # This can be reworked after a move to d.py 1.3.0+, which adds a CustomActivity class + if activity.name == 'Custom Status' and activity.state: + state = escape_markdown(activity.state) + custom_status = f'Status: {state}\n' + + name = str(user) + if user.nick: + name = f"{user.nick} ({name})" + + joined = time_since(user.joined_at, max_units=3) + roles = ", ".join(role.mention for role in user.roles[1:]) + + description = [ + textwrap.dedent(f""" + **User Information** + Created: {created} + Profile: {user.mention} + ID: {user.id} + {custom_status} + **Member Information** + Joined: {joined} + Roles: {roles or None} + """).strip() + ] + + # Show more verbose output in moderation channels for infractions and nominations + if ctx.channel.id in constants.MODERATION_CHANNELS: + description.append(await self.expanded_user_infraction_counts(user)) + description.append(await self.user_nomination_counts(user)) + else: + description.append(await self.basic_user_infraction_counts(user)) + + # Let's build the embed now + embed = Embed( + title=name, + description="\n\n".join(description) + ) + + embed.set_thumbnail(url=user.avatar_url_as(static_format="png")) + embed.colour = user.top_role.colour if roles else Colour.blurple() + + return embed + + async def basic_user_infraction_counts(self, member: Member) -> str: + """Gets the total and active infraction counts for the given `member`.""" + infractions = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'hidden': 'False', + 'user__id': str(member.id) + } + ) + + total_infractions = len(infractions) + active_infractions = sum(infraction['active'] for infraction in infractions) + + infraction_output = f"**Infractions**\nTotal: {total_infractions}\nActive: {active_infractions}" + + return infraction_output + + async def expanded_user_infraction_counts(self, member: Member) -> str: + """ + Gets expanded infraction counts for the given `member`. + + The counts will be split by infraction type and the number of active infractions for each type will indicated + in the output as well. + """ + infractions = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'user__id': str(member.id) + } + ) + + infraction_output = ["**Infractions**"] + if not infractions: + infraction_output.append("This user has never received an infraction.") + else: + # Count infractions split by `type` and `active` status for this user + infraction_types = set() + infraction_counter = defaultdict(int) + for infraction in infractions: + infraction_type = infraction["type"] + infraction_active = 'active' if infraction["active"] else 'inactive' + + infraction_types.add(infraction_type) + infraction_counter[f"{infraction_active} {infraction_type}"] += 1 + + # Format the output of the infraction counts + for infraction_type in sorted(infraction_types): + active_count = infraction_counter[f"active {infraction_type}"] + total_count = active_count + infraction_counter[f"inactive {infraction_type}"] + + line = f"{infraction_type.capitalize()}s: {total_count}" + if active_count: + line += f" ({active_count} active)" + + infraction_output.append(line) + + return "\n".join(infraction_output) + + async def user_nomination_counts(self, member: Member) -> str: + """Gets the active and historical nomination counts for the given `member`.""" + nominations = await self.bot.api_client.get( + 'bot/nominations', + params={ + 'user__id': str(member.id) + } + ) + + output = ["**Nominations**"] + + if not nominations: + output.append("This user has never been nominated.") + else: + count = len(nominations) + is_currently_nominated = any(nomination["active"] for nomination in nominations) + nomination_noun = "nomination" if count == 1 else "nominations" + + if is_currently_nominated: + output.append(f"This user is **currently** nominated ({count} {nomination_noun} in total).") + else: + output.append(f"This user has {count} historical {nomination_noun}, but is currently not nominated.") + + return "\n".join(output) + + def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str: + """Format a mapping to be readable to a human.""" + # sorting is technically superfluous but nice if you want to look for a specific field + fields = sorted(mapping.items(), key=lambda item: item[0]) + + if field_width is None: + field_width = len(max(mapping.keys(), key=len)) + + out = '' + + for key, val in fields: + if isinstance(val, dict): + # if we have dicts inside dicts we want to apply the same treatment to the inner dictionaries + inner_width = int(field_width * 1.6) + val = '\n' + self.format_fields(val, field_width=inner_width) + + elif isinstance(val, str): + # split up text since it might be long + text = textwrap.fill(val, width=100, replace_whitespace=False) + + # indent it, I guess you could do this with `wrap` and `join` but this is nicer + val = textwrap.indent(text, ' ' * (field_width + len(': '))) + + # the first line is already indented so we `str.lstrip` it + val = val.lstrip() + + if key == 'color': + # makes the base 10 representation of a hex number readable to humans + val = hex(val) + + out += '{0:>{width}}: {1}\n'.format(key, val, width=field_width) + + # remove trailing whitespace + return out.rstrip() + + @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES) + @group(invoke_without_command=True) + @in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_ROLES) + async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None: + """Shows information about the raw API response.""" + # I *guess* it could be deleted right as the command is invoked but I felt like it wasn't worth handling + # doing this extra request is also much easier than trying to convert everything back into a dictionary again + raw_data = await ctx.bot.http.get_message(message.channel.id, message.id) + + paginator = Paginator() + + def add_content(title: str, content: str) -> None: + paginator.add_line(f'== {title} ==\n') + # replace backticks as it breaks out of code blocks. Spaces seemed to be the most reasonable solution. + # we hope it's not close to 2000 + paginator.add_line(content.replace('```', '`` `')) + paginator.close_page() + + if message.content: + add_content('Raw message', message.content) + + transformer = pprint.pformat if json else self.format_fields + for field_name in ('embeds', 'attachments'): + data = raw_data[field_name] + + if not data: + continue + + total = len(data) + for current, item in enumerate(data, start=1): + title = f'Raw {field_name} ({current}/{total})' + add_content(title, transformer(item)) + + for page in paginator.pages: + await ctx.send(page) + + @raw.command() + async def json(self, ctx: Context, message: Message) -> None: + """Shows information about the raw API response in a copy-pasteable Python format.""" + await ctx.invoke(self.raw, message=message, json=True) + + +def setup(bot: Bot) -> None: + """Load the Information cog.""" + bot.add_cog(Information(bot)) diff --git a/bot/cogs/info/python_news.py b/bot/cogs/info/python_news.py new file mode 100644 index 000000000..0ab5738a4 --- /dev/null +++ b/bot/cogs/info/python_news.py @@ -0,0 +1,232 @@ +import logging +import typing as t +from datetime import date, datetime + +import discord +import feedparser +from bs4 import BeautifulSoup +from discord.ext.commands import Cog +from discord.ext.tasks import loop + +from bot import constants +from bot.bot import Bot +from bot.utils.webhooks import send_webhook + +PEPS_RSS_URL = "https://www.python.org/dev/peps/peps.rss/" + +RECENT_THREADS_TEMPLATE = "https://mail.python.org/archives/list/{name}@python.org/recent-threads" +THREAD_TEMPLATE_URL = "https://mail.python.org/archives/api/list/{name}@python.org/thread/{id}/" +MAILMAN_PROFILE_URL = "https://mail.python.org/archives/users/{id}/" +THREAD_URL = "https://mail.python.org/archives/list/{list}@python.org/thread/{id}/" + +AVATAR_URL = "https://www.python.org/static/opengraph-icon-200x200.png" + +log = logging.getLogger(__name__) + + +class PythonNews(Cog): + """Post new PEPs and Python News to `#python-news`.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.webhook_names = {} + self.webhook: t.Optional[discord.Webhook] = None + + self.bot.loop.create_task(self.get_webhook_names()) + self.bot.loop.create_task(self.get_webhook_and_channel()) + + async def start_tasks(self) -> None: + """Start the tasks for fetching new PEPs and mailing list messages.""" + self.fetch_new_media.start() + + @loop(minutes=20) + async def fetch_new_media(self) -> None: + """Fetch new mailing list messages and then new PEPs.""" + await self.post_maillist_news() + await self.post_pep_news() + + async def sync_maillists(self) -> None: + """Sync currently in-use maillists with API.""" + # Wait until guild is available to avoid running before everything is ready + await self.bot.wait_until_guild_available() + + response = await self.bot.api_client.get("bot/bot-settings/news") + for mail in constants.PythonNews.mail_lists: + if mail not in response["data"]: + response["data"][mail] = [] + + # Because we are handling PEPs differently, we don't include it to mail lists + if "pep" not in response["data"]: + response["data"]["pep"] = [] + + await self.bot.api_client.put("bot/bot-settings/news", json=response) + + async def get_webhook_names(self) -> None: + """Get webhook author names from maillist API.""" + await self.bot.wait_until_guild_available() + + async with self.bot.http_session.get("https://mail.python.org/archives/api/lists") as resp: + lists = await resp.json() + + for mail in lists: + if mail["name"].split("@")[0] in constants.PythonNews.mail_lists: + self.webhook_names[mail["name"].split("@")[0]] = mail["display_name"] + + async def post_pep_news(self) -> None: + """Fetch new PEPs and when they don't have announcement in #python-news, create it.""" + # Wait until everything is ready and http_session available + await self.bot.wait_until_guild_available() + await self.sync_maillists() + + async with self.bot.http_session.get(PEPS_RSS_URL) as resp: + data = feedparser.parse(await resp.text("utf-8")) + + news_listing = await self.bot.api_client.get("bot/bot-settings/news") + payload = news_listing.copy() + pep_numbers = news_listing["data"]["pep"] + + # Reverse entries to send oldest first + data["entries"].reverse() + for new in data["entries"]: + try: + new_datetime = datetime.strptime(new["published"], "%a, %d %b %Y %X %Z") + except ValueError: + log.warning(f"Wrong datetime format passed in PEP new: {new['published']}") + continue + pep_nr = new["title"].split(":")[0].split()[1] + if ( + pep_nr in pep_numbers + or new_datetime.date() < date.today() + ): + continue + + # Build an embed and send a webhook + embed = discord.Embed( + title=new["title"], + description=new["summary"], + timestamp=new_datetime, + url=new["link"], + colour=constants.Colours.soft_green + ) + embed.set_footer(text=data["feed"]["title"], icon_url=AVATAR_URL) + msg = await send_webhook( + webhook=self.webhook, + username=data["feed"]["title"], + embed=embed, + avatar_url=AVATAR_URL, + wait=True, + ) + payload["data"]["pep"].append(pep_nr) + + # Increase overall PEP new stat + self.bot.stats.incr("python_news.posted.pep") + + if msg.channel.is_news(): + log.trace("Publishing PEP annnouncement because it was in a news channel") + await msg.publish() + + # Apply new sent news to DB to avoid duplicate sending + await self.bot.api_client.put("bot/bot-settings/news", json=payload) + + async def post_maillist_news(self) -> None: + """Send new maillist threads to #python-news that is listed in configuration.""" + await self.bot.wait_until_guild_available() + await self.sync_maillists() + existing_news = await self.bot.api_client.get("bot/bot-settings/news") + payload = existing_news.copy() + + for maillist in constants.PythonNews.mail_lists: + async with self.bot.http_session.get(RECENT_THREADS_TEMPLATE.format(name=maillist)) as resp: + recents = BeautifulSoup(await resp.text(), features="lxml") + + # When a

element is present in the response then the mailing list + # has not had any activity during the current month, so therefore it + # can be ignored. + if recents.p: + continue + + for thread in recents.html.body.div.find_all("a", href=True): + # We want only these threads that have identifiers + if "latest" in thread["href"]: + continue + + thread_information, email_information = await self.get_thread_and_first_mail( + maillist, thread["href"].split("/")[-2] + ) + + try: + new_date = datetime.strptime(email_information["date"], "%Y-%m-%dT%X%z") + except ValueError: + log.warning(f"Invalid datetime from Thread email: {email_information['date']}") + continue + + if ( + thread_information["thread_id"] in existing_news["data"][maillist] + or 'Re: ' in thread_information["subject"] + or new_date.date() < date.today() + ): + continue + + content = email_information["content"] + link = THREAD_URL.format(id=thread["href"].split("/")[-2], list=maillist) + + # Build an embed and send a message to the webhook + embed = discord.Embed( + title=thread_information["subject"], + description=content[:500] + f"... [continue reading]({link})" if len(content) > 500 else content, + timestamp=new_date, + url=link, + colour=constants.Colours.soft_green + ) + embed.set_author( + name=f"{email_information['sender_name']} ({email_information['sender']['address']})", + url=MAILMAN_PROFILE_URL.format(id=email_information["sender"]["mailman_id"]), + ) + embed.set_footer( + text=f"Posted to {self.webhook_names[maillist]}", + icon_url=AVATAR_URL, + ) + msg = await send_webhook( + webhook=self.webhook, + username=self.webhook_names[maillist], + embed=embed, + avatar_url=AVATAR_URL, + wait=True, + ) + payload["data"][maillist].append(thread_information["thread_id"]) + + # Increase this specific maillist counter in stats + self.bot.stats.incr(f"python_news.posted.{maillist.replace('-', '_')}") + + if msg.channel.is_news(): + log.trace("Publishing mailing list message because it was in a news channel") + await msg.publish() + + await self.bot.api_client.put("bot/bot-settings/news", json=payload) + + async def get_thread_and_first_mail(self, maillist: str, thread_identifier: str) -> t.Tuple[t.Any, t.Any]: + """Get mail thread and first mail from mail.python.org based on `maillist` and `thread_identifier`.""" + async with self.bot.http_session.get( + THREAD_TEMPLATE_URL.format(name=maillist, id=thread_identifier) + ) as resp: + thread_information = await resp.json() + + async with self.bot.http_session.get(thread_information["starting_email"]) as resp: + email_information = await resp.json() + return thread_information, email_information + + async def get_webhook_and_channel(self) -> None: + """Storage #python-news channel Webhook and `TextChannel` to `News.webhook` and `channel`.""" + await self.bot.wait_until_guild_available() + self.webhook = await self.bot.fetch_webhook(constants.PythonNews.webhook) + + await self.start_tasks() + + def cog_unload(self) -> None: + """Stop news posting tasks on cog unload.""" + self.fetch_new_media.cancel() + + +def setup(bot: Bot) -> None: + """Add `News` cog.""" + bot.add_cog(PythonNews(bot)) diff --git a/bot/cogs/info/reddit.py b/bot/cogs/info/reddit.py new file mode 100644 index 000000000..d853ab2ea --- /dev/null +++ b/bot/cogs/info/reddit.py @@ -0,0 +1,304 @@ +import asyncio +import logging +import random +import textwrap +from collections import namedtuple +from datetime import datetime, timedelta +from typing import List + +from aiohttp import BasicAuth, ClientError +from discord import Colour, Embed, TextChannel +from discord.ext.commands import Cog, Context, group +from discord.ext.tasks import loop + +from bot.bot import Bot +from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES, Webhooks +from bot.converters import Subreddit +from bot.decorators import with_role +from bot.pagination import LinePaginator +from bot.utils.messages import sub_clyde + +log = logging.getLogger(__name__) + +AccessToken = namedtuple("AccessToken", ["token", "expires_at"]) + + +class Reddit(Cog): + """Track subreddit posts and show detailed statistics about them.""" + + HEADERS = {"User-Agent": "python3:python-discord/bot:1.0.0 (by /u/PythonDiscord)"} + URL = "https://www.reddit.com" + OAUTH_URL = "https://oauth.reddit.com" + MAX_RETRIES = 3 + + def __init__(self, bot: Bot): + self.bot = bot + + self.webhook = None + self.access_token = None + self.client_auth = BasicAuth(RedditConfig.client_id, RedditConfig.secret) + + bot.loop.create_task(self.init_reddit_ready()) + self.auto_poster_loop.start() + + def cog_unload(self) -> None: + """Stop the loop task and revoke the access token when the cog is unloaded.""" + self.auto_poster_loop.cancel() + if self.access_token and self.access_token.expires_at > datetime.utcnow(): + asyncio.create_task(self.revoke_access_token()) + + async def init_reddit_ready(self) -> None: + """Sets the reddit webhook when the cog is loaded.""" + await self.bot.wait_until_guild_available() + if not self.webhook: + self.webhook = await self.bot.fetch_webhook(Webhooks.reddit) + + @property + def channel(self) -> TextChannel: + """Get the #reddit channel object from the bot's cache.""" + return self.bot.get_channel(Channels.reddit) + + async def get_access_token(self) -> None: + """ + Get a Reddit API OAuth2 access token and assign it to self.access_token. + + A token is valid for 1 hour. There will be MAX_RETRIES to get a token, after which the cog + will be unloaded and a ClientError raised if retrieval was still unsuccessful. + """ + for i in range(1, self.MAX_RETRIES + 1): + response = await self.bot.http_session.post( + url=f"{self.URL}/api/v1/access_token", + headers=self.HEADERS, + auth=self.client_auth, + data={ + "grant_type": "client_credentials", + "duration": "temporary" + } + ) + + if response.status == 200 and response.content_type == "application/json": + content = await response.json() + expiration = int(content["expires_in"]) - 60 # Subtract 1 minute for leeway. + self.access_token = AccessToken( + token=content["access_token"], + expires_at=datetime.utcnow() + timedelta(seconds=expiration) + ) + + log.debug(f"New token acquired; expires on UTC {self.access_token.expires_at}") + return + else: + log.debug( + f"Failed to get an access token: " + f"status {response.status} & content type {response.content_type}; " + f"retrying ({i}/{self.MAX_RETRIES})" + ) + + await asyncio.sleep(3) + + self.bot.remove_cog(self.qualified_name) + raise ClientError("Authentication with the Reddit API failed. Unloading the cog.") + + async def revoke_access_token(self) -> None: + """ + Revoke the OAuth2 access token for the Reddit API. + + For security reasons, it's good practice to revoke the token when it's no longer being used. + """ + response = await self.bot.http_session.post( + url=f"{self.URL}/api/v1/revoke_token", + headers=self.HEADERS, + auth=self.client_auth, + data={ + "token": self.access_token.token, + "token_type_hint": "access_token" + } + ) + + if response.status == 204 and response.content_type == "application/json": + self.access_token = None + else: + log.warning(f"Unable to revoke access token: status {response.status}.") + + async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> List[dict]: + """A helper method to fetch a certain amount of Reddit posts at a given route.""" + # Reddit's JSON responses only provide 25 posts at most. + if not 25 >= amount > 0: + raise ValueError("Invalid amount of subreddit posts requested.") + + # Renew the token if necessary. + if not self.access_token or self.access_token.expires_at < datetime.utcnow(): + await self.get_access_token() + + url = f"{self.OAUTH_URL}/{route}" + for _ in range(self.MAX_RETRIES): + response = await self.bot.http_session.get( + url=url, + headers={**self.HEADERS, "Authorization": f"bearer {self.access_token.token}"}, + params=params + ) + if response.status == 200 and response.content_type == 'application/json': + # Got appropriate response - process and return. + content = await response.json() + posts = content["data"]["children"] + return posts[:amount] + + await asyncio.sleep(3) + + log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}") + return list() # Failed to get appropriate response within allowed number of retries. + + async def get_top_posts(self, subreddit: Subreddit, time: str = "all", amount: int = 5) -> Embed: + """ + Get the top amount of posts for a given subreddit within a specified timeframe. + + A time of "all" will get posts from all time, "day" will get top daily posts and "week" will get the top + weekly posts. + + The amount should be between 0 and 25 as Reddit's JSON requests only provide 25 posts at most. + """ + embed = Embed(description="") + + posts = await self.fetch_posts( + route=f"{subreddit}/top", + amount=amount, + params={"t": time} + ) + + if not posts: + embed.title = random.choice(ERROR_REPLIES) + embed.colour = Colour.red() + embed.description = ( + "Sorry! We couldn't find any posts from that subreddit. " + "If this problem persists, please let us know." + ) + + return embed + + for post in posts: + data = post["data"] + + text = data["selftext"] + if text: + text = textwrap.shorten(text, width=128, placeholder="...") + text += "\n" # Add newline to separate embed info + + ups = data["ups"] + comments = data["num_comments"] + author = data["author"] + + title = textwrap.shorten(data["title"], width=64, placeholder="...") + link = self.URL + data["permalink"] + + embed.description += ( + f"**[{title}]({link})**\n" + f"{text}" + f"{Emojis.upvotes} {ups} {Emojis.comments} {comments} {Emojis.user} {author}\n\n" + ) + + embed.colour = Colour.blurple() + return embed + + @loop() + async def auto_poster_loop(self) -> None: + """Post the top 5 posts daily, and the top 5 posts weekly.""" + # once we upgrade to d.py 1.3 this can be removed and the loop can use the `time=datetime.time.min` parameter + now = datetime.utcnow() + tomorrow = now + timedelta(days=1) + midnight_tomorrow = tomorrow.replace(hour=0, minute=0, second=0) + seconds_until = (midnight_tomorrow - now).total_seconds() + + await asyncio.sleep(seconds_until) + + await self.bot.wait_until_guild_available() + if not self.webhook: + await self.bot.fetch_webhook(Webhooks.reddit) + + if datetime.utcnow().weekday() == 0: + await self.top_weekly_posts() + # if it's a monday send the top weekly posts + + for subreddit in RedditConfig.subreddits: + top_posts = await self.get_top_posts(subreddit=subreddit, time="day") + username = sub_clyde(f"{subreddit} Top Daily Posts") + message = await self.webhook.send(username=username, embed=top_posts, wait=True) + + if message.channel.is_news(): + await message.publish() + + async def top_weekly_posts(self) -> None: + """Post a summary of the top posts.""" + for subreddit in RedditConfig.subreddits: + # Send and pin the new weekly posts. + top_posts = await self.get_top_posts(subreddit=subreddit, time="week") + username = sub_clyde(f"{subreddit} Top Weekly Posts") + message = await self.webhook.send(wait=True, username=username, embed=top_posts) + + if subreddit.lower() == "r/python": + if not self.channel: + log.warning("Failed to get #reddit channel to remove pins in the weekly loop.") + return + + # Remove the oldest pins so that only 12 remain at most. + pins = await self.channel.pins() + + while len(pins) >= 12: + await pins[-1].unpin() + del pins[-1] + + await message.pin() + + if message.channel.is_news(): + await message.publish() + + @group(name="reddit", invoke_without_command=True) + async def reddit_group(self, ctx: Context) -> None: + """View the top posts from various subreddits.""" + await ctx.send_help(ctx.command) + + @reddit_group.command(name="top") + async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: + """Send the top posts of all time from a given subreddit.""" + async with ctx.typing(): + embed = await self.get_top_posts(subreddit=subreddit, time="all") + + await ctx.send(content=f"Here are the top {subreddit} posts of all time!", embed=embed) + + @reddit_group.command(name="daily") + async def daily_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: + """Send the top posts of today from a given subreddit.""" + async with ctx.typing(): + embed = await self.get_top_posts(subreddit=subreddit, time="day") + + await ctx.send(content=f"Here are today's top {subreddit} posts!", embed=embed) + + @reddit_group.command(name="weekly") + async def weekly_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: + """Send the top posts of this week from a given subreddit.""" + async with ctx.typing(): + embed = await self.get_top_posts(subreddit=subreddit, time="week") + + await ctx.send(content=f"Here are this week's top {subreddit} posts!", embed=embed) + + @with_role(*STAFF_ROLES) + @reddit_group.command(name="subreddits", aliases=("subs",)) + async def subreddits_command(self, ctx: Context) -> None: + """Send a paginated embed of all the subreddits we're relaying.""" + embed = Embed() + embed.title = "Relayed subreddits." + embed.colour = Colour.blurple() + + await LinePaginator.paginate( + RedditConfig.subreddits, + ctx, embed, + footer_text="Use the reddit commands along with these to view their posts.", + empty=False, + max_lines=15 + ) + + +def setup(bot: Bot) -> None: + """Load the Reddit cog.""" + if not RedditConfig.secret or not RedditConfig.client_id: + log.error("Credentials not provided, cog not loaded.") + return + bot.add_cog(Reddit(bot)) diff --git a/bot/cogs/info/site.py b/bot/cogs/info/site.py new file mode 100644 index 000000000..ac29daa1d --- /dev/null +++ b/bot/cogs/info/site.py @@ -0,0 +1,146 @@ +import logging + +from discord import Colour, Embed +from discord.ext.commands import Cog, Context, group + +from bot.bot import Bot +from bot.constants import URLs +from bot.pagination import LinePaginator + +log = logging.getLogger(__name__) + +PAGES_URL = f"{URLs.site_schema}{URLs.site}/pages" + + +class Site(Cog): + """Commands for linking to different parts of the site.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @group(name="site", aliases=("s",), invoke_without_command=True) + async def site_group(self, ctx: Context) -> None: + """Commands for getting info about our website.""" + await ctx.send_help(ctx.command) + + @site_group.command(name="home", aliases=("about",)) + async def site_main(self, ctx: Context) -> None: + """Info about the website itself.""" + url = f"{URLs.site_schema}{URLs.site}/" + + embed = Embed(title="Python Discord website") + embed.set_footer(text=url) + embed.colour = Colour.blurple() + embed.description = ( + f"[Our official website]({url}) is an open-source community project " + "created with Python and Django. It contains information about the server " + "itself, lets you sign up for upcoming events, has its own wiki, contains " + "a list of valuable learning resources, and much more." + ) + + await ctx.send(embed=embed) + + @site_group.command(name="resources") + async def site_resources(self, ctx: Context) -> None: + """Info about the site's Resources page.""" + learning_url = f"{PAGES_URL}/resources" + + embed = Embed(title="Resources") + embed.set_footer(text=f"{learning_url}") + embed.colour = Colour.blurple() + embed.description = ( + f"The [Resources page]({learning_url}) on our website contains a " + "list of hand-selected learning resources that we regularly recommend " + f"to both beginners and experts." + ) + + await ctx.send(embed=embed) + + @site_group.command(name="tools") + async def site_tools(self, ctx: Context) -> None: + """Info about the site's Tools page.""" + tools_url = f"{PAGES_URL}/resources/tools" + + embed = Embed(title="Tools") + embed.set_footer(text=f"{tools_url}") + embed.colour = Colour.blurple() + embed.description = ( + f"The [Tools page]({tools_url}) on our website contains a " + f"couple of the most popular tools for programming in Python." + ) + + await ctx.send(embed=embed) + + @site_group.command(name="help") + async def site_help(self, ctx: Context) -> None: + """Info about the site's Getting Help page.""" + url = f"{PAGES_URL}/resources/guides/asking-good-questions" + + embed = Embed(title="Asking Good Questions") + embed.set_footer(text=url) + embed.colour = Colour.blurple() + embed.description = ( + "Asking the right question about something that's new to you can sometimes be tricky. " + f"To help with this, we've created a [guide to asking good questions]({url}) on our website. " + "It contains everything you need to get the very best help from our community." + ) + + await ctx.send(embed=embed) + + @site_group.command(name="faq") + async def site_faq(self, ctx: Context) -> None: + """Info about the site's FAQ page.""" + url = f"{PAGES_URL}/frequently-asked-questions" + + embed = Embed(title="FAQ") + embed.set_footer(text=url) + embed.colour = Colour.blurple() + embed.description = ( + "As the largest Python community on Discord, we get hundreds of questions every day. " + "Many of these questions have been asked before. We've compiled a list of the most " + "frequently asked questions along with their answers, which can be found on " + f"our [FAQ page]({url})." + ) + + await ctx.send(embed=embed) + + @site_group.command(aliases=['r', 'rule'], name='rules') + async def site_rules(self, ctx: Context, *rules: int) -> None: + """Provides a link to all rules or, if specified, displays specific rule(s).""" + rules_embed = Embed(title='Rules', color=Colour.blurple()) + rules_embed.url = f"{PAGES_URL}/rules" + + if not rules: + # Rules were not submitted. Return the default description. + rules_embed.description = ( + "The rules and guidelines that apply to this community can be found on" + f" our [rules page]({PAGES_URL}/rules). We expect" + " all members of the community to have read and understood these." + ) + + await ctx.send(embed=rules_embed) + return + + full_rules = await self.bot.api_client.get('rules', params={'link_format': 'md'}) + invalid_indices = tuple( + pick + for pick in rules + if pick < 1 or pick > len(full_rules) + ) + + if invalid_indices: + indices = ', '.join(map(str, invalid_indices)) + await ctx.send(f":x: Invalid rule indices: {indices}") + return + + for rule in rules: + self.bot.stats.incr(f"rule_uses.{rule}") + + final_rules = tuple(f"**{pick}.** {full_rules[pick - 1]}" for pick in rules) + + await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3) + + +def setup(bot: Bot) -> None: + """Load the Site cog.""" + bot.add_cog(Site(bot)) diff --git a/bot/cogs/info/source.py b/bot/cogs/info/source.py new file mode 100644 index 000000000..205e0ba81 --- /dev/null +++ b/bot/cogs/info/source.py @@ -0,0 +1,141 @@ +import inspect +from pathlib import Path +from typing import Optional, Tuple, Union + +from discord import Embed +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import URLs + +SourceType = Union[commands.HelpCommand, commands.Command, commands.Cog, str, commands.ExtensionNotLoaded] + + +class SourceConverter(commands.Converter): + """Convert an argument into a help command, tag, command, or cog.""" + + async def convert(self, ctx: commands.Context, argument: str) -> SourceType: + """Convert argument into source object.""" + if argument.lower().startswith("help"): + return ctx.bot.help_command + + cog = ctx.bot.get_cog(argument) + if cog: + return cog + + cmd = ctx.bot.get_command(argument) + if cmd: + return cmd + + tags_cog = ctx.bot.get_cog("Tags") + show_tag = True + + if not tags_cog: + show_tag = False + elif argument.lower() in tags_cog._cache: + return argument.lower() + + raise commands.BadArgument( + f"Unable to convert `{argument}` to valid command{', tag,' if show_tag else ''} or Cog." + ) + + +class BotSource(commands.Cog): + """Displays information about the bot's source code.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @commands.command(name="source", aliases=("src",)) + async def source_command(self, ctx: commands.Context, *, source_item: SourceConverter = None) -> None: + """Display information and a GitHub link to the source code of a command, tag, or cog.""" + if not source_item: + embed = Embed(title="Bot's GitHub Repository") + embed.add_field(name="Repository", value=f"[Go to GitHub]({URLs.github_bot_repo})") + embed.set_thumbnail(url="https://avatars1.githubusercontent.com/u/9919") + await ctx.send(embed=embed) + return + + embed = await self.build_embed(source_item) + await ctx.send(embed=embed) + + def get_source_link(self, source_item: SourceType) -> Tuple[str, str, Optional[int]]: + """ + Build GitHub link of source item, return this link, file location and first line number. + + Raise BadArgument if `source_item` is a dynamically-created object (e.g. via internal eval). + """ + if isinstance(source_item, commands.Command): + if source_item.cog_name == "Alias": + cmd_name = source_item.callback.__name__.replace("_alias", "") + cmd = self.bot.get_command(cmd_name.replace("_", " ")) + src = cmd.callback.__code__ + filename = src.co_filename + else: + src = source_item.callback.__code__ + filename = src.co_filename + elif isinstance(source_item, str): + tags_cog = self.bot.get_cog("Tags") + filename = tags_cog._cache[source_item]["location"] + else: + src = type(source_item) + try: + filename = inspect.getsourcefile(src) + except TypeError: + raise commands.BadArgument("Cannot get source for a dynamically-created object.") + + if not isinstance(source_item, str): + try: + lines, first_line_no = inspect.getsourcelines(src) + except OSError: + raise commands.BadArgument("Cannot get source for a dynamically-created object.") + + lines_extension = f"#L{first_line_no}-L{first_line_no+len(lines)-1}" + else: + first_line_no = None + lines_extension = "" + + # Handle tag file location differently than others to avoid errors in some cases + if not first_line_no: + file_location = Path(filename).relative_to("/bot/") + else: + file_location = Path(filename).relative_to(Path.cwd()).as_posix() + + url = f"{URLs.github_bot_repo}/blob/master/{file_location}{lines_extension}" + + return url, file_location, first_line_no or None + + async def build_embed(self, source_object: SourceType) -> Optional[Embed]: + """Build embed based on source object.""" + url, location, first_line = self.get_source_link(source_object) + + if isinstance(source_object, commands.HelpCommand): + title = "Help Command" + description = source_object.__doc__.splitlines()[1] + elif isinstance(source_object, commands.Command): + if source_object.cog_name == "Alias": + cmd_name = source_object.callback.__name__.replace("_alias", "") + cmd = self.bot.get_command(cmd_name.replace("_", " ")) + description = cmd.short_doc + else: + description = source_object.short_doc + + title = f"Command: {source_object.qualified_name}" + elif isinstance(source_object, str): + title = f"Tag: {source_object}" + description = "" + else: + title = f"Cog: {source_object.qualified_name}" + description = source_object.description.splitlines()[0] + + embed = Embed(title=title, description=description) + embed.add_field(name="Source Code", value=f"[Go to GitHub]({url})") + line_text = f":{first_line}" if first_line else "" + embed.set_footer(text=f"{location}{line_text}") + + return embed + + +def setup(bot: Bot) -> None: + """Load the BotSource cog.""" + bot.add_cog(BotSource(bot)) diff --git a/bot/cogs/info/stats.py b/bot/cogs/info/stats.py new file mode 100644 index 000000000..d42f55466 --- /dev/null +++ b/bot/cogs/info/stats.py @@ -0,0 +1,129 @@ +import string +from datetime import datetime + +from discord import Member, Message, Status +from discord.ext.commands import Cog, Context +from discord.ext.tasks import loop + +from bot.bot import Bot +from bot.constants import Categories, Channels, Guild, Stats as StatConf + + +CHANNEL_NAME_OVERRIDES = { + Channels.off_topic_0: "off_topic_0", + Channels.off_topic_1: "off_topic_1", + Channels.off_topic_2: "off_topic_2", + Channels.staff_lounge: "staff_lounge" +} + +ALLOWED_CHARS = string.ascii_letters + string.digits + "_" + + +class Stats(Cog): + """A cog which provides a way to hook onto Discord events and forward to stats.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.last_presence_update = None + self.update_guild_boost.start() + + @Cog.listener() + async def on_message(self, message: Message) -> None: + """Report message events in the server to statsd.""" + if message.guild is None: + return + + if message.guild.id != Guild.id: + return + + cat = getattr(message.channel, "category", None) + if cat is not None and cat.id == Categories.modmail: + if message.channel.id != Channels.incidents: + # Do not report modmail channels to stats, there are too many + # of them for interesting statistics to be drawn out of this. + return + + reformatted_name = message.channel.name.replace('-', '_') + + if CHANNEL_NAME_OVERRIDES.get(message.channel.id): + reformatted_name = CHANNEL_NAME_OVERRIDES.get(message.channel.id) + + reformatted_name = "".join(char for char in reformatted_name if char in ALLOWED_CHARS) + + stat_name = f"channels.{reformatted_name}" + self.bot.stats.incr(stat_name) + + # Increment the total message count + self.bot.stats.incr("messages") + + @Cog.listener() + async def on_command_completion(self, ctx: Context) -> None: + """Report completed commands to statsd.""" + command_name = ctx.command.qualified_name.replace(" ", "_") + + self.bot.stats.incr(f"commands.{command_name}") + + @Cog.listener() + async def on_member_join(self, member: Member) -> None: + """Update member count stat on member join.""" + if member.guild.id != Guild.id: + return + + self.bot.stats.gauge("guild.total_members", len(member.guild.members)) + + @Cog.listener() + async def on_member_leave(self, member: Member) -> None: + """Update member count stat on member leave.""" + if member.guild.id != Guild.id: + return + + self.bot.stats.gauge("guild.total_members", len(member.guild.members)) + + @Cog.listener() + async def on_member_update(self, _before: Member, after: Member) -> None: + """Update presence estimates on member update.""" + if after.guild.id != Guild.id: + return + + if self.last_presence_update: + if (datetime.now() - self.last_presence_update).seconds < StatConf.presence_update_timeout: + return + + self.last_presence_update = datetime.now() + + online = 0 + idle = 0 + dnd = 0 + offline = 0 + + for member in after.guild.members: + if member.status is Status.online: + online += 1 + elif member.status is Status.dnd: + dnd += 1 + elif member.status is Status.idle: + idle += 1 + elif member.status is Status.offline: + offline += 1 + + self.bot.stats.gauge("guild.status.online", online) + self.bot.stats.gauge("guild.status.idle", idle) + self.bot.stats.gauge("guild.status.do_not_disturb", dnd) + self.bot.stats.gauge("guild.status.offline", offline) + + @loop(hours=1) + async def update_guild_boost(self) -> None: + """Post the server boost level and tier every hour.""" + await self.bot.wait_until_guild_available() + g = self.bot.get_guild(Guild.id) + self.bot.stats.gauge("boost.amount", g.premium_subscription_count) + self.bot.stats.gauge("boost.tier", g.premium_tier) + + def cog_unload(self) -> None: + """Stop the boost statistic task on unload of the Cog.""" + self.update_guild_boost.stop() + + +def setup(bot: Bot) -> None: + """Load the stats cog.""" + bot.add_cog(Stats(bot)) diff --git a/bot/cogs/info/tags.py b/bot/cogs/info/tags.py new file mode 100644 index 000000000..3d76c5c08 --- /dev/null +++ b/bot/cogs/info/tags.py @@ -0,0 +1,277 @@ +import logging +import re +import time +from pathlib import Path +from typing import Callable, Dict, Iterable, List, Optional + +from discord import Colour, Embed, Member +from discord.ext.commands import Cog, Context, group + +from bot import constants +from bot.bot import Bot +from bot.converters import TagNameConverter +from bot.pagination import LinePaginator +from bot.utils.messages import wait_for_deletion + +log = logging.getLogger(__name__) + +TEST_CHANNELS = ( + constants.Channels.bot_commands, + constants.Channels.helpers +) + +REGEX_NON_ALPHABET = re.compile(r"[^a-z]", re.MULTILINE & re.IGNORECASE) +FOOTER_TEXT = f"To show a tag, type {constants.Bot.prefix}tags ." + + +class Tags(Cog): + """Save new tags and fetch existing tags.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.tag_cooldowns = {} + self._cache = self.get_tags() + + @staticmethod + def get_tags() -> dict: + """Get all tags.""" + cache = {} + + base_path = Path("bot", "resources", "tags") + for file in base_path.glob("**/*"): + if file.is_file(): + tag_title = file.stem + tag = { + "title": tag_title, + "embed": { + "description": file.read_text(encoding="utf8"), + }, + "restricted_to": "developers", + "location": f"/bot/{file}" + } + + # Convert to a list to allow negative indexing. + parents = list(file.relative_to(base_path).parents) + if len(parents) > 1: + # -1 would be '.' hence -2 is used as the index. + tag["restricted_to"] = parents[-2].name + + cache[tag_title] = tag + + return cache + + @staticmethod + def check_accessibility(user: Member, tag: dict) -> bool: + """Check if user can access a tag.""" + return tag["restricted_to"].lower() in [role.name.lower() for role in user.roles] + + @staticmethod + def _fuzzy_search(search: str, target: str) -> float: + """A simple scoring algorithm based on how many letters are found / total, with order in mind.""" + current, index = 0, 0 + _search = REGEX_NON_ALPHABET.sub('', search.lower()) + _targets = iter(REGEX_NON_ALPHABET.split(target.lower())) + _target = next(_targets) + try: + while True: + while index < len(_target) and _search[current] == _target[index]: + current += 1 + index += 1 + index, _target = 0, next(_targets) + except (StopIteration, IndexError): + pass + return current / len(_search) * 100 + + def _get_suggestions(self, tag_name: str, thresholds: Optional[List[int]] = None) -> List[str]: + """Return a list of suggested tags.""" + scores: Dict[str, int] = { + tag_title: Tags._fuzzy_search(tag_name, tag['title']) + for tag_title, tag in self._cache.items() + } + + thresholds = thresholds or [100, 90, 80, 70, 60] + + for threshold in thresholds: + suggestions = [ + self._cache[tag_title] + for tag_title, matching_score in scores.items() + if matching_score >= threshold + ] + if suggestions: + return suggestions + + return [] + + def _get_tag(self, tag_name: str) -> list: + """Get a specific tag.""" + found = [self._cache.get(tag_name.lower(), None)] + if not found[0]: + return self._get_suggestions(tag_name) + return found + + def _get_tags_via_content(self, check: Callable[[Iterable], bool], keywords: str, user: Member) -> list: + """ + Search for tags via contents. + + `predicate` will be the built-in any, all, or a custom callable. Must return a bool. + """ + keywords_processed: List[str] = [] + for keyword in keywords.split(','): + keyword_sanitized = keyword.strip().casefold() + if not keyword_sanitized: + # this happens when there are leading / trailing / consecutive comma. + continue + keywords_processed.append(keyword_sanitized) + + if not keywords_processed: + # after sanitizing, we can end up with an empty list, for example when keywords is ',' + # in that case, we simply want to search for such keywords directly instead. + keywords_processed = [keywords] + + matching_tags = [] + for tag in self._cache.values(): + matches = (query in tag['embed']['description'].casefold() for query in keywords_processed) + if self.check_accessibility(user, tag) and check(matches): + matching_tags.append(tag) + + return matching_tags + + async def _send_matching_tags(self, ctx: Context, keywords: str, matching_tags: list) -> None: + """Send the result of matching tags to user.""" + if not matching_tags: + pass + elif len(matching_tags) == 1: + await ctx.send(embed=Embed().from_dict(matching_tags[0]['embed'])) + else: + is_plural = keywords.strip().count(' ') > 0 or keywords.strip().count(',') > 0 + embed = Embed( + title=f"Here are the tags containing the given keyword{'s' * is_plural}:", + description='\n'.join(tag['title'] for tag in matching_tags[:10]) + ) + await LinePaginator.paginate( + sorted(f"**»** {tag['title']}" for tag in matching_tags), + ctx, + embed, + footer_text=FOOTER_TEXT, + empty=False, + max_lines=15 + ) + + @group(name='tags', aliases=('tag', 't'), invoke_without_command=True) + async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: + """Show all known tags, a single tag, or run a subcommand.""" + await ctx.invoke(self.get_command, tag_name=tag_name) + + @tags_group.group(name='search', invoke_without_command=True) + async def search_tag_content(self, ctx: Context, *, keywords: str) -> None: + """ + Search inside tags' contents for tags. Allow searching for multiple keywords separated by comma. + + Only search for tags that has ALL the keywords. + """ + matching_tags = self._get_tags_via_content(all, keywords, ctx.author) + await self._send_matching_tags(ctx, keywords, matching_tags) + + @search_tag_content.command(name='any') + async def search_tag_content_any_keyword(self, ctx: Context, *, keywords: Optional[str] = 'any') -> None: + """ + Search inside tags' contents for tags. Allow searching for multiple keywords separated by comma. + + Search for tags that has ANY of the keywords. + """ + matching_tags = self._get_tags_via_content(any, keywords or 'any', ctx.author) + await self._send_matching_tags(ctx, keywords, matching_tags) + + @tags_group.command(name='get', aliases=('show', 'g')) + async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: + """Get a specified tag, or a list of all tags if no tag is specified.""" + + def _command_on_cooldown(tag_name: str) -> bool: + """ + Check if the command is currently on cooldown, on a per-tag, per-channel basis. + + The cooldown duration is set in constants.py. + """ + now = time.time() + + cooldown_conditions = ( + tag_name + and tag_name in self.tag_cooldowns + and (now - self.tag_cooldowns[tag_name]["time"]) < constants.Cooldowns.tags + and self.tag_cooldowns[tag_name]["channel"] == ctx.channel.id + ) + + if cooldown_conditions: + return True + return False + + if _command_on_cooldown(tag_name): + time_elapsed = time.time() - self.tag_cooldowns[tag_name]["time"] + time_left = constants.Cooldowns.tags - time_elapsed + log.info( + f"{ctx.author} tried to get the '{tag_name}' tag, but the tag is on cooldown. " + f"Cooldown ends in {time_left:.1f} seconds." + ) + return + + if tag_name is not None: + temp_founds = self._get_tag(tag_name) + + founds = [] + + for found_tag in temp_founds: + if self.check_accessibility(ctx.author, found_tag): + founds.append(found_tag) + + if len(founds) == 1: + tag = founds[0] + if ctx.channel.id not in TEST_CHANNELS: + self.tag_cooldowns[tag_name] = { + "time": time.time(), + "channel": ctx.channel.id + } + + self.bot.stats.incr(f"tags.usages.{tag['title'].replace('-', '_')}") + + await wait_for_deletion( + await ctx.send(embed=Embed.from_dict(tag['embed'])), + [ctx.author.id], + client=self.bot + ) + elif founds and len(tag_name) >= 3: + await wait_for_deletion( + await ctx.send( + embed=Embed( + title='Did you mean ...', + description='\n'.join(tag['title'] for tag in founds[:10]) + ) + ), + [ctx.author.id], + client=self.bot + ) + + else: + tags = self._cache.values() + if not tags: + await ctx.send(embed=Embed( + description="**There are no tags in the database!**", + colour=Colour.red() + )) + else: + embed: Embed = Embed(title="**Current tags**") + await LinePaginator.paginate( + sorted( + f"**»** {tag['title']}" for tag in tags + if self.check_accessibility(ctx.author, tag) + ), + ctx, + embed, + footer_text=FOOTER_TEXT, + empty=False, + max_lines=15 + ) + + +def setup(bot: Bot) -> None: + """Load the Tags cog.""" + bot.add_cog(Tags(bot)) diff --git a/bot/cogs/info/wolfram.py b/bot/cogs/info/wolfram.py new file mode 100644 index 000000000..e6cae3bb8 --- /dev/null +++ b/bot/cogs/info/wolfram.py @@ -0,0 +1,280 @@ +import logging +from io import BytesIO +from typing import Callable, List, Optional, Tuple +from urllib import parse + +import discord +from dateutil.relativedelta import relativedelta +from discord import Embed +from discord.ext import commands +from discord.ext.commands import BucketType, Cog, Context, check, group + +from bot.bot import Bot +from bot.constants import Colours, STAFF_ROLES, Wolfram +from bot.pagination import ImagePaginator +from bot.utils.time import humanize_delta + +log = logging.getLogger(__name__) + +APPID = Wolfram.key +DEFAULT_OUTPUT_FORMAT = "JSON" +QUERY = "http://api.wolframalpha.com/v2/{request}?{data}" +WOLF_IMAGE = "https://www.symbols.com/gi.php?type=1&id=2886&i=1" + +MAX_PODS = 20 + +# Allows for 10 wolfram calls pr user pr day +usercd = commands.CooldownMapping.from_cooldown(Wolfram.user_limit_day, 60*60*24, BucketType.user) + +# Allows for max api requests / days in month per day for the entire guild (Temporary) +guildcd = commands.CooldownMapping.from_cooldown(Wolfram.guild_limit_day, 60*60*24, BucketType.guild) + + +async def send_embed( + ctx: Context, + message_txt: str, + colour: int = Colours.soft_red, + footer: str = None, + img_url: str = None, + f: discord.File = None +) -> None: + """Generate & send a response embed with Wolfram as the author.""" + embed = Embed(colour=colour) + embed.description = message_txt + embed.set_author(name="Wolfram Alpha", + icon_url=WOLF_IMAGE, + url="https://www.wolframalpha.com/") + if footer: + embed.set_footer(text=footer) + + if img_url: + embed.set_image(url=img_url) + + await ctx.send(embed=embed, file=f) + + +def custom_cooldown(*ignore: List[int]) -> Callable: + """ + Implement per-user and per-guild cooldowns for requests to the Wolfram API. + + A list of roles may be provided to ignore the per-user cooldown + """ + async def predicate(ctx: Context) -> bool: + if ctx.invoked_with == 'help': + # if the invoked command is help we don't want to increase the ratelimits since it's not actually + # invoking the command/making a request, so instead just check if the user/guild are on cooldown. + guild_cooldown = not guildcd.get_bucket(ctx.message).get_tokens() == 0 # if guild is on cooldown + if not any(r.id in ignore for r in ctx.author.roles): # check user bucket if user is not ignored + return guild_cooldown and not usercd.get_bucket(ctx.message).get_tokens() == 0 + return guild_cooldown + + user_bucket = usercd.get_bucket(ctx.message) + + if all(role.id not in ignore for role in ctx.author.roles): + user_rate = user_bucket.update_rate_limit() + + if user_rate: + # Can't use api; cause: member limit + delta = relativedelta(seconds=int(user_rate)) + cooldown = humanize_delta(delta) + message = ( + "You've used up your limit for Wolfram|Alpha requests.\n" + f"Cooldown: {cooldown}" + ) + await send_embed(ctx, message) + return False + + guild_bucket = guildcd.get_bucket(ctx.message) + guild_rate = guild_bucket.update_rate_limit() + + # Repr has a token attribute to read requests left + log.debug(guild_bucket) + + if guild_rate: + # Can't use api; cause: guild limit + message = ( + "The max limit of requests for the server has been reached for today.\n" + f"Cooldown: {int(guild_rate)}" + ) + await send_embed(ctx, message) + return False + + return True + return check(predicate) + + +async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[List[Tuple]]: + """Get the Wolfram API pod pages for the provided query.""" + async with ctx.channel.typing(): + url_str = parse.urlencode({ + "input": query, + "appid": APPID, + "output": DEFAULT_OUTPUT_FORMAT, + "format": "image,plaintext" + }) + request_url = QUERY.format(request="query", data=url_str) + + async with bot.http_session.get(request_url) as response: + json = await response.json(content_type='text/plain') + + result = json["queryresult"] + + if result["error"]: + # API key not set up correctly + if result["error"]["msg"] == "Invalid appid": + message = "Wolfram API key is invalid or missing." + log.warning( + "API key seems to be missing, or invalid when " + f"processing a wolfram request: {url_str}, Response: {json}" + ) + await send_embed(ctx, message) + return + + message = "Something went wrong internally with your request, please notify staff!" + log.warning(f"Something went wrong getting a response from wolfram: {url_str}, Response: {json}") + await send_embed(ctx, message) + return + + if not result["success"]: + message = f"I couldn't find anything for {query}." + await send_embed(ctx, message) + return + + if not result["numpods"]: + message = "Could not find any results." + await send_embed(ctx, message) + return + + pods = result["pods"] + pages = [] + for pod in pods[:MAX_PODS]: + subs = pod.get("subpods") + + for sub in subs: + title = sub.get("title") or sub.get("plaintext") or sub.get("id", "") + img = sub["img"]["src"] + pages.append((title, img)) + return pages + + +class Wolfram(Cog): + """Commands for interacting with the Wolfram|Alpha API.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @group(name="wolfram", aliases=("wolf", "wa"), invoke_without_command=True) + @custom_cooldown(*STAFF_ROLES) + async def wolfram_command(self, ctx: Context, *, query: str) -> None: + """Requests all answers on a single image, sends an image of all related pods.""" + url_str = parse.urlencode({ + "i": query, + "appid": APPID, + }) + query = QUERY.format(request="simple", data=url_str) + + # Give feedback that the bot is working. + async with ctx.channel.typing(): + async with self.bot.http_session.get(query) as response: + status = response.status + image_bytes = await response.read() + + f = discord.File(BytesIO(image_bytes), filename="image.png") + image_url = "attachment://image.png" + + if status == 501: + message = "Failed to get response" + footer = "" + color = Colours.soft_red + elif status == 400: + message = "No input found" + footer = "" + color = Colours.soft_red + elif status == 403: + message = "Wolfram API key is invalid or missing." + footer = "" + color = Colours.soft_red + else: + message = "" + footer = "View original for a bigger picture." + color = Colours.soft_orange + + # Sends a "blank" embed if no request is received, unsure how to fix + await send_embed(ctx, message, color, footer=footer, img_url=image_url, f=f) + + @wolfram_command.command(name="page", aliases=("pa", "p")) + @custom_cooldown(*STAFF_ROLES) + async def wolfram_page_command(self, ctx: Context, *, query: str) -> None: + """ + Requests a drawn image of given query. + + Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. + """ + pages = await get_pod_pages(ctx, self.bot, query) + + if not pages: + return + + embed = Embed() + embed.set_author(name="Wolfram Alpha", + icon_url=WOLF_IMAGE, + url="https://www.wolframalpha.com/") + embed.colour = Colours.soft_orange + + await ImagePaginator.paginate(pages, ctx, embed) + + @wolfram_command.command(name="cut", aliases=("c",)) + @custom_cooldown(*STAFF_ROLES) + async def wolfram_cut_command(self, ctx: Context, *, query: str) -> None: + """ + Requests a drawn image of given query. + + Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. + """ + pages = await get_pod_pages(ctx, self.bot, query) + + if not pages: + return + + if len(pages) >= 2: + page = pages[1] + else: + page = pages[0] + + await send_embed(ctx, page[0], colour=Colours.soft_orange, img_url=page[1]) + + @wolfram_command.command(name="short", aliases=("sh", "s")) + @custom_cooldown(*STAFF_ROLES) + async def wolfram_short_command(self, ctx: Context, *, query: str) -> None: + """Requests an answer to a simple question.""" + url_str = parse.urlencode({ + "i": query, + "appid": APPID, + }) + query = QUERY.format(request="result", data=url_str) + + # Give feedback that the bot is working. + async with ctx.channel.typing(): + async with self.bot.http_session.get(query) as response: + status = response.status + response_text = await response.text() + + if status == 501: + message = "Failed to get response" + color = Colours.soft_red + elif status == 400: + message = "No input found" + color = Colours.soft_red + elif response_text == "Error 1: Invalid appid": + message = "Wolfram API key is invalid or missing." + color = Colours.soft_red + else: + message = response_text + color = Colours.soft_orange + + await send_embed(ctx, message, color) + + +def setup(bot: Bot) -> None: + """Load the Wolfram cog.""" + bot.add_cog(Wolfram(bot)) diff --git a/bot/cogs/information.py b/bot/cogs/information.py deleted file mode 100644 index 8982196d1..000000000 --- a/bot/cogs/information.py +++ /dev/null @@ -1,422 +0,0 @@ -import colorsys -import logging -import pprint -import textwrap -from collections import Counter, defaultdict -from string import Template -from typing import Any, Mapping, Optional, Union - -from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, utils -from discord.abc import GuildChannel -from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group -from discord.utils import escape_markdown - -from bot import constants -from bot.bot import Bot -from bot.decorators import in_whitelist, with_role -from bot.pagination import LinePaginator -from bot.utils.checks import InWhitelistCheckFailure, cooldown_with_role_bypass, with_role_check -from bot.utils.time import time_since - -log = logging.getLogger(__name__) - - -class Information(Cog): - """A cog with commands for generating embeds with server info, such as server stats and user info.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @staticmethod - def role_can_read(channel: GuildChannel, role: Role) -> bool: - """Return True if `role` can read messages in `channel`.""" - overwrites = channel.overwrites_for(role) - return overwrites.read_messages is True - - def get_staff_channel_count(self, guild: Guild) -> int: - """ - Get the number of channels that are staff-only. - - We need to know two things about a channel: - - Does the @everyone role have explicit read deny permissions? - - Do staff roles have explicit read allow permissions? - - If the answer to both of these questions is yes, it's a staff channel. - """ - channel_ids = set() - for channel in guild.channels: - if channel.type is ChannelType.category: - continue - - everyone_can_read = self.role_can_read(channel, guild.default_role) - - for role in constants.STAFF_ROLES: - role_can_read = self.role_can_read(channel, guild.get_role(role)) - if role_can_read and not everyone_can_read: - channel_ids.add(channel.id) - break - - return len(channel_ids) - - @staticmethod - def get_channel_type_counts(guild: Guild) -> str: - """Return the total amounts of the various types of channels in `guild`.""" - channel_counter = Counter(c.type for c in guild.channels) - channel_type_list = [] - for channel, count in channel_counter.items(): - channel_type = str(channel).title() - channel_type_list.append(f"{channel_type} channels: {count}") - - channel_type_list = sorted(channel_type_list) - return "\n".join(channel_type_list) - - @with_role(*constants.MODERATION_ROLES) - @command(name="roles") - async def roles_info(self, ctx: Context) -> None: - """Returns a list of all roles and their corresponding IDs.""" - # Sort the roles alphabetically and remove the @everyone role - roles = sorted(ctx.guild.roles[1:], key=lambda role: role.name) - - # Build a list - role_list = [] - for role in roles: - role_list.append(f"`{role.id}` - {role.mention}") - - # Build an embed - embed = Embed( - title=f"Role information (Total {len(roles)} role{'s' * (len(role_list) > 1)})", - colour=Colour.blurple() - ) - - await LinePaginator.paginate(role_list, ctx, embed, empty=False) - - @with_role(*constants.MODERATION_ROLES) - @command(name="role") - async def role_info(self, ctx: Context, *roles: Union[Role, str]) -> None: - """ - Return information on a role or list of roles. - - To specify multiple roles just add to the arguments, delimit roles with spaces in them using quotation marks. - """ - parsed_roles = [] - failed_roles = [] - - for role_name in roles: - if isinstance(role_name, Role): - # Role conversion has already succeeded - parsed_roles.append(role_name) - continue - - role = utils.find(lambda r: r.name.lower() == role_name.lower(), ctx.guild.roles) - - if not role: - failed_roles.append(role_name) - continue - - parsed_roles.append(role) - - if failed_roles: - await ctx.send(f":x: Could not retrieve the following roles: {', '.join(failed_roles)}") - - for role in parsed_roles: - h, s, v = colorsys.rgb_to_hsv(*role.colour.to_rgb()) - - embed = Embed( - title=f"{role.name} info", - colour=role.colour, - ) - embed.add_field(name="ID", value=role.id, inline=True) - embed.add_field(name="Colour (RGB)", value=f"#{role.colour.value:0>6x}", inline=True) - embed.add_field(name="Colour (HSV)", value=f"{h:.2f} {s:.2f} {v}", inline=True) - embed.add_field(name="Member count", value=len(role.members), inline=True) - embed.add_field(name="Position", value=role.position) - embed.add_field(name="Permission code", value=role.permissions.value, inline=True) - - await ctx.send(embed=embed) - - @command(name="server", aliases=["server_info", "guild", "guild_info"]) - async def server_info(self, ctx: Context) -> None: - """Returns an embed full of server information.""" - created = time_since(ctx.guild.created_at, precision="days") - features = ", ".join(ctx.guild.features) - region = ctx.guild.region - - roles = len(ctx.guild.roles) - member_count = ctx.guild.member_count - channel_counts = self.get_channel_type_counts(ctx.guild) - - # How many of each user status? - statuses = Counter(member.status for member in ctx.guild.members) - embed = Embed(colour=Colour.blurple()) - - # How many staff members and staff channels do we have? - staff_member_count = len(ctx.guild.get_role(constants.Roles.helpers).members) - staff_channel_count = self.get_staff_channel_count(ctx.guild) - - # Because channel_counts lacks leading whitespace, it breaks the dedent if it's inserted directly by the - # f-string. While this is correctly formated by Discord, it makes unit testing difficult. To keep the formatting - # without joining a tuple of strings we can use a Template string to insert the already-formatted channel_counts - # after the dedent is made. - embed.description = Template( - textwrap.dedent(f""" - **Server information** - Created: {created} - Voice region: {region} - Features: {features} - - **Channel counts** - $channel_counts - Staff channels: {staff_channel_count} - - **Member counts** - Members: {member_count:,} - Staff members: {staff_member_count} - Roles: {roles} - - **Member statuses** - {constants.Emojis.status_online} {statuses[Status.online]:,} - {constants.Emojis.status_idle} {statuses[Status.idle]:,} - {constants.Emojis.status_dnd} {statuses[Status.dnd]:,} - {constants.Emojis.status_offline} {statuses[Status.offline]:,} - """) - ).substitute({"channel_counts": channel_counts}) - embed.set_thumbnail(url=ctx.guild.icon_url) - - await ctx.send(embed=embed) - - @command(name="user", aliases=["user_info", "member", "member_info"]) - async def user_info(self, ctx: Context, user: Member = None) -> None: - """Returns info about a user.""" - if user is None: - user = ctx.author - - # Do a role check if this is being executed on someone other than the caller - elif user != ctx.author and not with_role_check(ctx, *constants.MODERATION_ROLES): - await ctx.send("You may not use this command on users other than yourself.") - return - - # Non-staff may only do this in #bot-commands - if not with_role_check(ctx, *constants.STAFF_ROLES): - if not ctx.channel.id == constants.Channels.bot_commands: - raise InWhitelistCheckFailure(constants.Channels.bot_commands) - - embed = await self.create_user_embed(ctx, user) - - await ctx.send(embed=embed) - - async def create_user_embed(self, ctx: Context, user: Member) -> Embed: - """Creates an embed containing information on the `user`.""" - created = time_since(user.created_at, max_units=3) - - # Custom status - custom_status = '' - for activity in user.activities: - # Check activity.state for None value if user has a custom status set - # This guards against a custom status with an emoji but no text, which will cause - # escape_markdown to raise an exception - # This can be reworked after a move to d.py 1.3.0+, which adds a CustomActivity class - if activity.name == 'Custom Status' and activity.state: - state = escape_markdown(activity.state) - custom_status = f'Status: {state}\n' - - name = str(user) - if user.nick: - name = f"{user.nick} ({name})" - - joined = time_since(user.joined_at, max_units=3) - roles = ", ".join(role.mention for role in user.roles[1:]) - - description = [ - textwrap.dedent(f""" - **User Information** - Created: {created} - Profile: {user.mention} - ID: {user.id} - {custom_status} - **Member Information** - Joined: {joined} - Roles: {roles or None} - """).strip() - ] - - # Show more verbose output in moderation channels for infractions and nominations - if ctx.channel.id in constants.MODERATION_CHANNELS: - description.append(await self.expanded_user_infraction_counts(user)) - description.append(await self.user_nomination_counts(user)) - else: - description.append(await self.basic_user_infraction_counts(user)) - - # Let's build the embed now - embed = Embed( - title=name, - description="\n\n".join(description) - ) - - embed.set_thumbnail(url=user.avatar_url_as(static_format="png")) - embed.colour = user.top_role.colour if roles else Colour.blurple() - - return embed - - async def basic_user_infraction_counts(self, member: Member) -> str: - """Gets the total and active infraction counts for the given `member`.""" - infractions = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'hidden': 'False', - 'user__id': str(member.id) - } - ) - - total_infractions = len(infractions) - active_infractions = sum(infraction['active'] for infraction in infractions) - - infraction_output = f"**Infractions**\nTotal: {total_infractions}\nActive: {active_infractions}" - - return infraction_output - - async def expanded_user_infraction_counts(self, member: Member) -> str: - """ - Gets expanded infraction counts for the given `member`. - - The counts will be split by infraction type and the number of active infractions for each type will indicated - in the output as well. - """ - infractions = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'user__id': str(member.id) - } - ) - - infraction_output = ["**Infractions**"] - if not infractions: - infraction_output.append("This user has never received an infraction.") - else: - # Count infractions split by `type` and `active` status for this user - infraction_types = set() - infraction_counter = defaultdict(int) - for infraction in infractions: - infraction_type = infraction["type"] - infraction_active = 'active' if infraction["active"] else 'inactive' - - infraction_types.add(infraction_type) - infraction_counter[f"{infraction_active} {infraction_type}"] += 1 - - # Format the output of the infraction counts - for infraction_type in sorted(infraction_types): - active_count = infraction_counter[f"active {infraction_type}"] - total_count = active_count + infraction_counter[f"inactive {infraction_type}"] - - line = f"{infraction_type.capitalize()}s: {total_count}" - if active_count: - line += f" ({active_count} active)" - - infraction_output.append(line) - - return "\n".join(infraction_output) - - async def user_nomination_counts(self, member: Member) -> str: - """Gets the active and historical nomination counts for the given `member`.""" - nominations = await self.bot.api_client.get( - 'bot/nominations', - params={ - 'user__id': str(member.id) - } - ) - - output = ["**Nominations**"] - - if not nominations: - output.append("This user has never been nominated.") - else: - count = len(nominations) - is_currently_nominated = any(nomination["active"] for nomination in nominations) - nomination_noun = "nomination" if count == 1 else "nominations" - - if is_currently_nominated: - output.append(f"This user is **currently** nominated ({count} {nomination_noun} in total).") - else: - output.append(f"This user has {count} historical {nomination_noun}, but is currently not nominated.") - - return "\n".join(output) - - def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str: - """Format a mapping to be readable to a human.""" - # sorting is technically superfluous but nice if you want to look for a specific field - fields = sorted(mapping.items(), key=lambda item: item[0]) - - if field_width is None: - field_width = len(max(mapping.keys(), key=len)) - - out = '' - - for key, val in fields: - if isinstance(val, dict): - # if we have dicts inside dicts we want to apply the same treatment to the inner dictionaries - inner_width = int(field_width * 1.6) - val = '\n' + self.format_fields(val, field_width=inner_width) - - elif isinstance(val, str): - # split up text since it might be long - text = textwrap.fill(val, width=100, replace_whitespace=False) - - # indent it, I guess you could do this with `wrap` and `join` but this is nicer - val = textwrap.indent(text, ' ' * (field_width + len(': '))) - - # the first line is already indented so we `str.lstrip` it - val = val.lstrip() - - if key == 'color': - # makes the base 10 representation of a hex number readable to humans - val = hex(val) - - out += '{0:>{width}}: {1}\n'.format(key, val, width=field_width) - - # remove trailing whitespace - return out.rstrip() - - @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES) - @group(invoke_without_command=True) - @in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_ROLES) - async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None: - """Shows information about the raw API response.""" - # I *guess* it could be deleted right as the command is invoked but I felt like it wasn't worth handling - # doing this extra request is also much easier than trying to convert everything back into a dictionary again - raw_data = await ctx.bot.http.get_message(message.channel.id, message.id) - - paginator = Paginator() - - def add_content(title: str, content: str) -> None: - paginator.add_line(f'== {title} ==\n') - # replace backticks as it breaks out of code blocks. Spaces seemed to be the most reasonable solution. - # we hope it's not close to 2000 - paginator.add_line(content.replace('```', '`` `')) - paginator.close_page() - - if message.content: - add_content('Raw message', message.content) - - transformer = pprint.pformat if json else self.format_fields - for field_name in ('embeds', 'attachments'): - data = raw_data[field_name] - - if not data: - continue - - total = len(data) - for current, item in enumerate(data, start=1): - title = f'Raw {field_name} ({current}/{total})' - add_content(title, transformer(item)) - - for page in paginator.pages: - await ctx.send(page) - - @raw.command() - async def json(self, ctx: Context, message: Message) -> None: - """Shows information about the raw API response in a copy-pasteable Python format.""" - await ctx.invoke(self.raw, message=message, json=True) - - -def setup(bot: Bot) -> None: - """Load the Information cog.""" - bot.add_cog(Information(bot)) diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py deleted file mode 100644 index b3102db2f..000000000 --- a/bot/cogs/jams.py +++ /dev/null @@ -1,150 +0,0 @@ -import logging -import typing as t - -from discord import CategoryChannel, Guild, Member, PermissionOverwrite, Role -from discord.ext import commands -from more_itertools import unique_everseen - -from bot.bot import Bot -from bot.constants import Roles -from bot.decorators import with_role - -log = logging.getLogger(__name__) - -MAX_CHANNELS = 50 -CATEGORY_NAME = "Code Jam" - - -class CodeJams(commands.Cog): - """Manages the code-jam related parts of our server.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @commands.command() - @with_role(Roles.admins) - async def createteam(self, ctx: commands.Context, team_name: str, members: commands.Greedy[Member]) -> None: - """ - Create team channels (voice and text) in the Code Jams category, assign roles, and add overwrites for the team. - - The first user passed will always be the team leader. - """ - # Ignore duplicate members - members = list(unique_everseen(members)) - - # We had a little issue during Code Jam 4 here, the greedy converter did it's job - # and ignored anything which wasn't a valid argument which left us with teams of - # two members or at some times even 1 member. This fixes that by checking that there - # are always 3 members in the members list. - if len(members) < 3: - await ctx.send( - ":no_entry_sign: One of your arguments was invalid\n" - f"There must be a minimum of 3 valid members in your team. Found: {len(members)}" - " members" - ) - return - - team_channel = await self.create_channels(ctx.guild, team_name, members) - await self.add_roles(ctx.guild, members) - - await ctx.send( - f":ok_hand: Team created: {team_channel}\n" - f"**Team Leader:** {members[0].mention}\n" - f"**Team Members:** {' '.join(member.mention for member in members[1:])}" - ) - - async def get_category(self, guild: Guild) -> CategoryChannel: - """ - Return a code jam category. - - If all categories are full or none exist, create a new category. - """ - for category in guild.categories: - # Need 2 available spaces: one for the text channel and one for voice. - if category.name == CATEGORY_NAME and MAX_CHANNELS - len(category.channels) >= 2: - return category - - return await self.create_category(guild) - - @staticmethod - async def create_category(guild: Guild) -> CategoryChannel: - """Create a new code jam category and return it.""" - log.info("Creating a new code jam category.") - - category_overwrites = { - guild.default_role: PermissionOverwrite(read_messages=False), - guild.me: PermissionOverwrite(read_messages=True) - } - - return await guild.create_category_channel( - CATEGORY_NAME, - overwrites=category_overwrites, - reason="It's code jam time!" - ) - - @staticmethod - def get_overwrites(members: t.List[Member], guild: Guild) -> t.Dict[t.Union[Member, Role], PermissionOverwrite]: - """Get code jam team channels permission overwrites.""" - # First member is always the team leader - team_channel_overwrites = { - members[0]: PermissionOverwrite( - manage_messages=True, - read_messages=True, - manage_webhooks=True, - connect=True - ), - guild.default_role: PermissionOverwrite(read_messages=False, connect=False), - guild.get_role(Roles.verified): PermissionOverwrite( - read_messages=False, - connect=False - ) - } - - # Rest of members should just have read_messages - for member in members[1:]: - team_channel_overwrites[member] = PermissionOverwrite( - read_messages=True, - connect=True - ) - - return team_channel_overwrites - - async def create_channels(self, guild: Guild, team_name: str, members: t.List[Member]) -> str: - """Create team text and voice channels. Return the mention for the text channel.""" - # Get permission overwrites and category - team_channel_overwrites = self.get_overwrites(members, guild) - code_jam_category = await self.get_category(guild) - - # Create a text channel for the team - team_channel = await guild.create_text_channel( - team_name, - overwrites=team_channel_overwrites, - category=code_jam_category - ) - - # Create a voice channel for the team - team_voice_name = " ".join(team_name.split("-")).title() - - await guild.create_voice_channel( - team_voice_name, - overwrites=team_channel_overwrites, - category=code_jam_category - ) - - return team_channel.mention - - @staticmethod - async def add_roles(guild: Guild, members: t.List[Member]) -> None: - """Assign team leader and jammer roles.""" - # Assign team leader role - await members[0].add_roles(guild.get_role(Roles.team_leaders)) - - # Assign rest of roles - jammer_role = guild.get_role(Roles.jammers) - for member in members: - await member.add_roles(jammer_role) - - -def setup(bot: Bot) -> None: - """Load the CodeJams cog.""" - bot.add_cog(CodeJams(bot)) diff --git a/bot/cogs/logging.py b/bot/cogs/logging.py deleted file mode 100644 index 94fa2b139..000000000 --- a/bot/cogs/logging.py +++ /dev/null @@ -1,42 +0,0 @@ -import logging - -from discord import Embed -from discord.ext.commands import Cog - -from bot.bot import Bot -from bot.constants import Channels, DEBUG_MODE - - -log = logging.getLogger(__name__) - - -class Logging(Cog): - """Debug logging module.""" - - def __init__(self, bot: Bot): - self.bot = bot - - self.bot.loop.create_task(self.startup_greeting()) - - async def startup_greeting(self) -> None: - """Announce our presence to the configured devlog channel.""" - await self.bot.wait_until_guild_available() - log.info("Bot connected!") - - embed = Embed(description="Connected!") - embed.set_author( - name="Python Bot", - url="https://github.com/python-discord/bot", - icon_url=( - "https://raw.githubusercontent.com/" - "python-discord/branding/master/logos/logo_circle/logo_circle_large.png" - ) - ) - - if not DEBUG_MODE: - await self.bot.get_channel(Channels.dev_log).send(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the Logging cog.""" - bot.add_cog(Logging(bot)) diff --git a/bot/cogs/moderation/__init__.py b/bot/cogs/moderation/__init__.py index 995187ef0..aad1f3c26 100644 --- a/bot/cogs/moderation/__init__.py +++ b/bot/cogs/moderation/__init__.py @@ -1,11 +1,11 @@ from bot.bot import Bot from .incidents import Incidents -from .infractions import Infractions -from .management import ModManagement +from .infraction.infractions import Infractions +from .infraction.management import ModManagement +from .infraction.superstarify import Superstarify from .modlog import ModLog from .silence import Silence from .slowmode import Slowmode -from .superstarify import Superstarify def setup(bot: Bot) -> None: diff --git a/bot/cogs/moderation/defcon.py b/bot/cogs/moderation/defcon.py new file mode 100644 index 000000000..4c0ad5914 --- /dev/null +++ b/bot/cogs/moderation/defcon.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +import logging +from collections import namedtuple +from datetime import datetime, timedelta +from enum import Enum + +from discord import Colour, Embed, Member +from discord.ext.commands import Cog, Context, group + +from bot.bot import Bot +from bot.cogs.moderation import ModLog +from bot.constants import Channels, Colours, Emojis, Event, Icons, Roles +from bot.decorators import with_role + +log = logging.getLogger(__name__) + +REJECTION_MESSAGE = """ +Hi, {user} - Thanks for your interest in our server! + +Due to a current (or detected) cyberattack on our community, we've limited access to the server for new accounts. Since +your account is relatively new, we're unable to provide access to the server at this time. + +Even so, thanks for joining! We're very excited at the possibility of having you here, and we hope that this situation +will be resolved soon. In the meantime, please feel free to peruse the resources on our site at +, and have a nice day! +""" + +BASE_CHANNEL_TOPIC = "Python Discord Defense Mechanism" + + +class Action(Enum): + """Defcon Action.""" + + ActionInfo = namedtuple('LogInfoDetails', ['icon', 'color', 'template']) + + ENABLED = ActionInfo(Icons.defcon_enabled, Colours.soft_green, "**Days:** {days}\n\n") + DISABLED = ActionInfo(Icons.defcon_disabled, Colours.soft_red, "") + UPDATED = ActionInfo(Icons.defcon_updated, Colour.blurple(), "**Days:** {days}\n\n") + + +class Defcon(Cog): + """Time-sensitive server defense mechanisms.""" + + days = None # type: timedelta + enabled = False # type: bool + + def __init__(self, bot: Bot): + self.bot = bot + self.channel = None + self.days = timedelta(days=0) + + self.bot.loop.create_task(self.sync_settings()) + + @property + def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + async def sync_settings(self) -> None: + """On cog load, try to synchronize DEFCON settings to the API.""" + await self.bot.wait_until_guild_available() + self.channel = await self.bot.fetch_channel(Channels.defcon) + + try: + response = await self.bot.api_client.get('bot/bot-settings/defcon') + data = response['data'] + + except Exception: # Yikes! + log.exception("Unable to get DEFCON settings!") + await self.bot.get_channel(Channels.dev_log).send( + f"<@&{Roles.admins}> **WARNING**: Unable to get DEFCON settings!" + ) + + else: + if data["enabled"]: + self.enabled = True + self.days = timedelta(days=data["days"]) + log.info(f"DEFCON enabled: {self.days.days} days") + + else: + self.enabled = False + self.days = timedelta(days=0) + log.info("DEFCON disabled") + + await self.update_channel_topic() + + @Cog.listener() + async def on_member_join(self, member: Member) -> None: + """If DEFCON is enabled, check newly joining users to see if they meet the account age threshold.""" + if self.enabled and self.days.days > 0: + now = datetime.utcnow() + + if now - member.created_at < self.days: + log.info(f"Rejecting user {member}: Account is too new and DEFCON is enabled") + + message_sent = False + + try: + await member.send(REJECTION_MESSAGE.format(user=member.mention)) + + message_sent = True + except Exception: + log.exception(f"Unable to send rejection message to user: {member}") + + await member.kick(reason="DEFCON active, user is too new") + self.bot.stats.incr("defcon.leaves") + + message = ( + f"{member} (`{member.id}`) was denied entry because their account is too new." + ) + + if not message_sent: + message = f"{message}\n\nUnable to send rejection message via DM; they probably have DMs disabled." + + await self.mod_log.send_log_message( + Icons.defcon_denied, Colours.soft_red, "Entry denied", + message, member.avatar_url_as(static_format="png") + ) + + @group(name='defcon', aliases=('dc',), invoke_without_command=True) + @with_role(Roles.admins, Roles.owners) + async def defcon_group(self, ctx: Context) -> None: + """Check the DEFCON status or run a subcommand.""" + await ctx.send_help(ctx.command) + + async def _defcon_action(self, ctx: Context, days: int, action: Action) -> None: + """Providing a structured way to do an defcon action.""" + try: + response = await self.bot.api_client.get('bot/bot-settings/defcon') + data = response['data'] + + if "enable_date" in data and action is Action.DISABLED: + enabled = datetime.fromisoformat(data["enable_date"]) + + delta = datetime.now() - enabled + + self.bot.stats.timing("defcon.enabled", delta) + except Exception: + pass + + error = None + try: + await self.bot.api_client.put( + 'bot/bot-settings/defcon', + json={ + 'name': 'defcon', + 'data': { + # TODO: retrieve old days count + 'days': days, + 'enabled': action is not Action.DISABLED, + 'enable_date': datetime.now().isoformat() + } + } + ) + except Exception as err: + log.exception("Unable to update DEFCON settings.") + error = err + finally: + await ctx.send(self.build_defcon_msg(action, error)) + await self.send_defcon_log(action, ctx.author, error) + + self.bot.stats.gauge("defcon.threshold", days) + + @defcon_group.command(name='enable', aliases=('on', 'e')) + @with_role(Roles.admins, Roles.owners) + async def enable_command(self, ctx: Context) -> None: + """ + Enable DEFCON mode. Useful in a pinch, but be sure you know what you're doing! + + Currently, this just adds an account age requirement. Use !defcon days to set how old an account must be, + in days. + """ + self.enabled = True + await self._defcon_action(ctx, days=0, action=Action.ENABLED) + await self.update_channel_topic() + + @defcon_group.command(name='disable', aliases=('off', 'd')) + @with_role(Roles.admins, Roles.owners) + async def disable_command(self, ctx: Context) -> None: + """Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!""" + self.enabled = False + await self._defcon_action(ctx, days=0, action=Action.DISABLED) + await self.update_channel_topic() + + @defcon_group.command(name='status', aliases=('s',)) + @with_role(Roles.admins, Roles.owners) + async def status_command(self, ctx: Context) -> None: + """Check the current status of DEFCON mode.""" + embed = Embed( + colour=Colour.blurple(), title="DEFCON Status", + description=f"**Enabled:** {self.enabled}\n" + f"**Days:** {self.days.days}" + ) + + await ctx.send(embed=embed) + + @defcon_group.command(name='days') + @with_role(Roles.admins, Roles.owners) + async def days_command(self, ctx: Context, days: int) -> None: + """Set how old an account must be to join the server, in days, with DEFCON mode enabled.""" + self.days = timedelta(days=days) + self.enabled = True + await self._defcon_action(ctx, days=days, action=Action.UPDATED) + await self.update_channel_topic() + + async def update_channel_topic(self) -> None: + """Update the #defcon channel topic with the current DEFCON status.""" + if self.enabled: + day_str = "days" if self.days.days > 1 else "day" + new_topic = f"{BASE_CHANNEL_TOPIC}\n(Status: Enabled, Threshold: {self.days.days} {day_str})" + else: + new_topic = f"{BASE_CHANNEL_TOPIC}\n(Status: Disabled)" + + self.mod_log.ignore(Event.guild_channel_update, Channels.defcon) + await self.channel.edit(topic=new_topic) + + def build_defcon_msg(self, action: Action, e: Exception = None) -> str: + """Build in-channel response string for DEFCON action.""" + if action is Action.ENABLED: + msg = f"{Emojis.defcon_enabled} DEFCON enabled.\n\n" + elif action is Action.DISABLED: + msg = f"{Emojis.defcon_disabled} DEFCON disabled.\n\n" + elif action is Action.UPDATED: + msg = ( + f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {self.days.days} " + f"day{'s' if self.days.days > 1 else ''} old to join the server.\n\n" + ) + + if e: + msg += ( + "**There was a problem updating the site** - This setting may be reverted when the bot restarts.\n\n" + f"```py\n{e}\n```" + ) + + return msg + + async def send_defcon_log(self, action: Action, actor: Member, e: Exception = None) -> None: + """Send log message for DEFCON action.""" + info = action.value + log_msg: str = ( + f"**Staffer:** {actor.mention} {actor} (`{actor.id}`)\n" + f"{info.template.format(days=self.days.days)}" + ) + status_msg = f"DEFCON {action.name.lower()}" + + if e: + log_msg += ( + "**There was a problem updating the site** - This setting may be reverted when the bot restarts.\n\n" + f"```py\n{e}\n```" + ) + + await self.mod_log.send_log_message(info.icon, info.color, status_msg, log_msg) + + +def setup(bot: Bot) -> None: + """Load the Defcon cog.""" + bot.add_cog(Defcon(bot)) diff --git a/bot/cogs/moderation/infraction/__init__.py b/bot/cogs/moderation/infraction/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bot/cogs/moderation/infraction/infractions.py b/bot/cogs/moderation/infraction/infractions.py new file mode 100644 index 000000000..8df642428 --- /dev/null +++ b/bot/cogs/moderation/infraction/infractions.py @@ -0,0 +1,370 @@ +import logging +import textwrap +import typing as t + +import discord +from discord import Member +from discord.ext import commands +from discord.ext.commands import Context, command + +from bot import constants +from bot.bot import Bot +from bot.constants import Event +from bot.converters import Expiry, FetchedMember +from bot.decorators import respect_role_hierarchy +from bot.utils.checks import with_role_check +from . import utils +from .scheduler import InfractionScheduler +from .utils import UserSnowflake + +log = logging.getLogger(__name__) + + +class Infractions(InfractionScheduler, commands.Cog): + """Apply and pardon infractions on users for moderation purposes.""" + + category = "Moderation" + category_description = "Server moderation tools." + + def __init__(self, bot: Bot): + super().__init__(bot, supported_infractions={"ban", "kick", "mute", "note", "warning"}) + + self.category = "Moderation" + self._muted_role = discord.Object(constants.Roles.muted) + + @commands.Cog.listener() + async def on_member_join(self, member: Member) -> None: + """Reapply active mute infractions for returning members.""" + active_mutes = await self.bot.api_client.get( + "bot/infractions", + params={ + "active": "true", + "type": "mute", + "user__id": member.id + } + ) + + if active_mutes: + reason = f"Re-applying active mute: {active_mutes[0]['id']}" + action = member.add_roles(self._muted_role, reason=reason) + + await self.reapply_infraction(active_mutes[0], action) + + # region: Permanent infractions + + @command() + async def warn(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: + """Warn a user for the given reason.""" + infraction = await utils.post_infraction(ctx, user, "warning", reason, active=False) + if infraction is None: + return + + await self.apply_infraction(ctx, infraction, user) + + @command() + async def kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: + """Kick a user for the given reason.""" + await self.apply_kick(ctx, user, reason) + + @command() + async def ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: + """Permanently ban a user for the given reason and stop watching them with Big Brother.""" + await self.apply_ban(ctx, user, reason) + + # endregion + # region: Temporary infractions + + @command(aliases=["mute"]) + async def tempmute(self, ctx: Context, user: Member, duration: Expiry, *, reason: t.Optional[str] = None) -> None: + """ + Temporarily mute a user for the given reason and duration. + + 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. + """ + await self.apply_mute(ctx, user, reason, expires_at=duration) + + @command() + async def tempban( + self, + ctx: Context, + user: FetchedMember, + duration: Expiry, + *, + reason: t.Optional[str] = None + ) -> None: + """ + Temporarily ban a user for the given reason and duration. + + 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. + """ + await self.apply_ban(ctx, user, reason, expires_at=duration) + + # endregion + # region: Permanent shadow infractions + + @command(hidden=True) + async def note(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: + """Create a private note for a user with the given reason without notifying the user.""" + infraction = await utils.post_infraction(ctx, user, "note", reason, hidden=True, active=False) + if infraction is None: + return + + await self.apply_infraction(ctx, infraction, user) + + @command(hidden=True, aliases=['shadowkick', 'skick']) + async def shadow_kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: + """Kick a user for the given reason without notifying the user.""" + await self.apply_kick(ctx, user, reason, hidden=True) + + @command(hidden=True, aliases=['shadowban', 'sban']) + async def shadow_ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: + """Permanently ban a user for the given reason without notifying the user.""" + await self.apply_ban(ctx, user, reason, hidden=True) + + # endregion + # region: Temporary shadow infractions + + @command(hidden=True, aliases=["shadowtempmute, stempmute", "shadowmute", "smute"]) + async def shadow_tempmute( + self, ctx: Context, + user: Member, + duration: Expiry, + *, + reason: t.Optional[str] = None + ) -> None: + """ + Temporarily mute a user for the given reason and duration without notifying the user. + + 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. + """ + await self.apply_mute(ctx, user, reason, expires_at=duration, hidden=True) + + @command(hidden=True, aliases=["shadowtempban, stempban"]) + async def shadow_tempban( + self, + ctx: Context, + user: FetchedMember, + duration: Expiry, + *, + reason: t.Optional[str] = None + ) -> None: + """ + Temporarily ban a user for the given reason and duration without notifying the user. + + 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. + """ + await self.apply_ban(ctx, user, reason, expires_at=duration, hidden=True) + + # endregion + # region: Remove infractions (un- commands) + + @command() + async def unmute(self, ctx: Context, user: FetchedMember) -> None: + """Prematurely end the active mute infraction for the user.""" + await self.pardon_infraction(ctx, "mute", user) + + @command() + async def unban(self, ctx: Context, user: FetchedMember) -> None: + """Prematurely end the active ban infraction for the user.""" + await self.pardon_infraction(ctx, "ban", user) + + # endregion + # region: Base apply functions + + async def apply_mute(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None: + """Apply a mute infraction with kwargs passed to `post_infraction`.""" + if await utils.get_active_infraction(ctx, user, "mute"): + return + + infraction = await utils.post_infraction(ctx, user, "mute", reason, active=True, **kwargs) + if infraction is None: + return + + self.mod_log.ignore(Event.member_update, user.id) + + async def action() -> None: + await user.add_roles(self._muted_role, reason=reason) + + log.trace(f"Attempting to kick {user} from voice because they've been muted.") + await user.move_to(None, reason=reason) + + await self.apply_infraction(ctx, infraction, user, action()) + + @respect_role_hierarchy() + async def apply_kick(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None: + """Apply a kick infraction with kwargs passed to `post_infraction`.""" + infraction = await utils.post_infraction(ctx, user, "kick", reason, active=False, **kwargs) + if infraction is None: + return + + self.mod_log.ignore(Event.member_remove, user.id) + + if reason: + reason = textwrap.shorten(reason, width=512, placeholder="...") + + action = user.kick(reason=reason) + await self.apply_infraction(ctx, infraction, user, action) + + @respect_role_hierarchy() + async def apply_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None: + """ + Apply a ban infraction with kwargs passed to `post_infraction`. + + Will also remove the banned user from the Big Brother watch list if applicable. + """ + # In the case of a permanent ban, we don't need get_active_infractions to tell us if one is active + is_temporary = kwargs.get("expires_at") is not None + active_infraction = await utils.get_active_infraction(ctx, user, "ban", is_temporary) + + if active_infraction: + if is_temporary: + log.trace("Tempban ignored as it cannot overwrite an active ban.") + return + + if active_infraction.get('expires_at') is None: + log.trace("Permaban already exists, notify.") + await ctx.send(f":x: User is already permanently banned (#{active_infraction['id']}).") + return + + log.trace("Old tempban is being replaced by new permaban.") + await self.pardon_infraction(ctx, "ban", user, is_temporary) + + infraction = await utils.post_infraction(ctx, user, "ban", reason, active=True, **kwargs) + if infraction is None: + return + + self.mod_log.ignore(Event.member_remove, user.id) + + if reason: + reason = textwrap.shorten(reason, width=512, placeholder="...") + + action = ctx.guild.ban(user, reason=reason, delete_message_days=0) + await self.apply_infraction(ctx, infraction, user, action) + + if infraction.get('expires_at') is not None: + log.trace(f"Ban isn't permanent; user {user} won't be unwatched by Big Brother.") + return + + bb_cog = self.bot.get_cog("Big Brother") + if not bb_cog: + log.error(f"Big Brother cog not loaded; perma-banned user {user} won't be unwatched.") + return + + log.trace(f"Big Brother cog loaded; attempting to unwatch perma-banned user {user}.") + + bb_reason = "User has been permanently banned from the server. Automatically removed." + await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False) + + # endregion + # region: Base pardon functions + + async def pardon_mute(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]: + """Remove a user's muted role, DM them a notification, and return a log dict.""" + user = guild.get_member(user_id) + log_text = {} + + if user: + # Remove the muted role. + self.mod_log.ignore(Event.member_update, user.id) + await user.remove_roles(self._muted_role, reason=reason) + + # DM the user about the expiration. + notified = await utils.notify_pardon( + user=user, + title="You have been unmuted", + content="You may now send messages in the server.", + icon_url=utils.INFRACTION_ICONS["mute"][1] + ) + + log_text["Member"] = f"{user.mention}(`{user.id}`)" + log_text["DM"] = "Sent" if notified else "**Failed**" + else: + log.info(f"Failed to unmute user {user_id}: user not found") + log_text["Failure"] = "User was not found in the guild." + + return log_text + + async def pardon_ban(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]: + """Remove a user's ban on the Discord guild and return a log dict.""" + user = discord.Object(user_id) + log_text = {} + + self.mod_log.ignore(Event.member_unban, user_id) + + try: + await guild.unban(user, reason=reason) + except discord.NotFound: + log.info(f"Failed to unban user {user_id}: no active ban found on Discord") + log_text["Note"] = "No active ban found on Discord." + + return log_text + + async def _pardon_action(self, infraction: utils.Infraction) -> t.Optional[t.Dict[str, str]]: + """ + Execute deactivation steps specific to the infraction's type and return a log dict. + + If an infraction type is unsupported, return None instead. + """ + guild = self.bot.get_guild(constants.Guild.id) + user_id = infraction["user"] + reason = f"Infraction #{infraction['id']} expired or was pardoned." + + if infraction["type"] == "mute": + return await self.pardon_mute(user_id, guild, reason) + elif infraction["type"] == "ban": + return await self.pardon_ban(user_id, guild, reason) + + # endregion + + # This cannot be static (must have a __func__ attribute). + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + return with_role_check(ctx, *constants.MODERATION_ROLES) + + # This cannot be static (must have a __func__ attribute). + async def cog_command_error(self, ctx: Context, error: Exception) -> None: + """Send a notification to the invoking context on a Union failure.""" + if isinstance(error, commands.BadUnionArgument): + if discord.User in error.converters or discord.Member in error.converters: + await ctx.send(str(error.errors[0])) + error.handled = True diff --git a/bot/cogs/moderation/infraction/management.py b/bot/cogs/moderation/infraction/management.py new file mode 100644 index 000000000..791585b6e --- /dev/null +++ b/bot/cogs/moderation/infraction/management.py @@ -0,0 +1,305 @@ +import logging +import textwrap +import typing as t +from datetime import datetime + +import discord +from discord.ext import commands +from discord.ext.commands import Context + +from bot import constants +from bot.bot import Bot +from bot.cogs.moderation.modlog import ModLog +from bot.converters import Expiry, InfractionSearchQuery, allowed_strings, proxy_user +from bot.pagination import LinePaginator +from bot.utils import time +from bot.utils.checks import in_whitelist_check, with_role_check +from . import utils +from .infractions import Infractions + +log = logging.getLogger(__name__) + + +class ModManagement(commands.Cog): + """Management of infractions.""" + + category = "Moderation" + + def __init__(self, bot: Bot): + self.bot = bot + + @property + def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + @property + def infractions_cog(self) -> Infractions: + """Get currently loaded Infractions cog instance.""" + return self.bot.get_cog("Infractions") + + # region: Edit infraction commands + + @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True) + async def infraction_group(self, ctx: Context) -> None: + """Infraction manipulation commands.""" + await ctx.send_help(ctx.command) + + @infraction_group.command(name='edit') + async def infraction_edit( + self, + ctx: Context, + infraction_id: t.Union[int, allowed_strings("l", "last", "recent")], # noqa: F821 + duration: t.Union[Expiry, allowed_strings("p", "permanent"), None], # noqa: F821 + *, + reason: str = None + ) -> None: + """ + Edit the duration and/or the reason of an infraction. + + Durations are relative to the time of updating and should be appended with a unit of time. + Units (∗case-sensitive): + \u2003`y` - years + \u2003`m` - months∗ + \u2003`w` - weeks + \u2003`d` - days + \u2003`h` - hours + \u2003`M` - minutes∗ + \u2003`s` - seconds + + Use "l", "last", or "recent" as the infraction ID to specify that the most recent infraction + authored by the command invoker should be edited. + + Use "p" or "permanent" to mark the infraction as permanent. Alternatively, an ISO 8601 + timestamp can be provided for the duration. + """ + if duration is None and reason is None: + # Unlike UserInputError, the error handler will show a specified message for BadArgument + raise commands.BadArgument("Neither a new expiry nor a new reason was specified.") + + # Retrieve the previous infraction for its information. + if isinstance(infraction_id, str): + params = { + "actor__id": ctx.author.id, + "ordering": "-inserted_at" + } + infractions = await self.bot.api_client.get("bot/infractions", params=params) + + if infractions: + old_infraction = infractions[0] + infraction_id = old_infraction["id"] + else: + await ctx.send( + ":x: Couldn't find most recent infraction; you have never given an infraction." + ) + return + else: + old_infraction = await self.bot.api_client.get(f"bot/infractions/{infraction_id}") + + request_data = {} + confirm_messages = [] + log_text = "" + + if duration is not None and not old_infraction['active']: + if reason is None: + await ctx.send(":x: Cannot edit the expiration of an expired infraction.") + return + confirm_messages.append("expiry unchanged (infraction already expired)") + elif isinstance(duration, str): + request_data['expires_at'] = None + confirm_messages.append("marked as permanent") + elif duration is not None: + request_data['expires_at'] = duration.isoformat() + expiry = time.format_infraction_with_duration(request_data['expires_at']) + confirm_messages.append(f"set to expire on {expiry}") + else: + confirm_messages.append("expiry unchanged") + + if reason: + request_data['reason'] = reason + confirm_messages.append("set a new reason") + log_text += f""" + Previous reason: {old_infraction['reason']} + New reason: {reason} + """.rstrip() + else: + confirm_messages.append("reason unchanged") + + # Update the infraction + new_infraction = await self.bot.api_client.patch( + f'bot/infractions/{infraction_id}', + json=request_data, + ) + + # Re-schedule infraction if the expiration has been updated + if 'expires_at' in request_data: + # A scheduled task should only exist if the old infraction wasn't permanent + if old_infraction['expires_at']: + self.infractions_cog.scheduler.cancel(new_infraction['id']) + + # If the infraction was not marked as permanent, schedule a new expiration task + if request_data['expires_at']: + self.infractions_cog.schedule_expiration(new_infraction) + + log_text += f""" + Previous expiry: {old_infraction['expires_at'] or "Permanent"} + New expiry: {new_infraction['expires_at'] or "Permanent"} + """.rstrip() + + changes = ' & '.join(confirm_messages) + await ctx.send(f":ok_hand: Updated infraction #{infraction_id}: {changes}") + + # Get information about the infraction's user + user_id = new_infraction['user'] + user = ctx.guild.get_member(user_id) + + if user: + user_text = f"{user.mention} (`{user.id}`)" + thumbnail = user.avatar_url_as(static_format="png") + else: + user_text = f"`{user_id}`" + thumbnail = None + + # The infraction's actor + actor_id = new_infraction['actor'] + actor = ctx.guild.get_member(actor_id) or f"`{actor_id}`" + + await self.mod_log.send_log_message( + icon_url=constants.Icons.pencil, + colour=discord.Colour.blurple(), + title="Infraction edited", + thumbnail=thumbnail, + text=textwrap.dedent(f""" + Member: {user_text} + Actor: {actor} + Edited by: {ctx.message.author}{log_text} + """) + ) + + # endregion + # region: Search infractions + + @infraction_group.group(name="search", invoke_without_command=True) + async def infraction_search_group(self, ctx: Context, query: InfractionSearchQuery) -> None: + """Searches for infractions in the database.""" + if isinstance(query, discord.User): + await ctx.invoke(self.search_user, query) + else: + await ctx.invoke(self.search_reason, query) + + @infraction_search_group.command(name="user", aliases=("member", "id")) + async def search_user(self, ctx: Context, user: t.Union[discord.User, proxy_user]) -> None: + """Search for infractions by member.""" + infraction_list = await self.bot.api_client.get( + 'bot/infractions', + params={'user__id': str(user.id)} + ) + embed = discord.Embed( + title=f"Infractions for {user} ({len(infraction_list)} total)", + colour=discord.Colour.orange() + ) + await self.send_infraction_list(ctx, embed, infraction_list) + + @infraction_search_group.command(name="reason", aliases=("match", "regex", "re")) + async def search_reason(self, ctx: Context, reason: str) -> None: + """Search for infractions by their reason. Use Re2 for matching.""" + infraction_list = await self.bot.api_client.get( + 'bot/infractions', + params={'search': reason} + ) + embed = discord.Embed( + title=f"Infractions matching `{reason}` ({len(infraction_list)} total)", + colour=discord.Colour.orange() + ) + await self.send_infraction_list(ctx, embed, infraction_list) + + # endregion + # region: Utility functions + + async def send_infraction_list( + self, + ctx: Context, + embed: discord.Embed, + infractions: t.Iterable[utils.Infraction] + ) -> None: + """Send a paginated embed of infractions for the specified user.""" + if not infractions: + await ctx.send(":warning: No infractions could be found for that query.") + return + + lines = tuple( + self.infraction_to_string(infraction) + for infraction in infractions + ) + + await LinePaginator.paginate( + lines, + ctx=ctx, + embed=embed, + empty=True, + max_lines=3, + max_size=1000 + ) + + def infraction_to_string(self, infraction: utils.Infraction) -> str: + """Convert the infraction object to a string representation.""" + actor_id = infraction["actor"] + guild = self.bot.get_guild(constants.Guild.id) + actor = guild.get_member(actor_id) + active = infraction["active"] + user_id = infraction["user"] + hidden = infraction["hidden"] + created = time.format_infraction(infraction["inserted_at"]) + + if active: + remaining = time.until_expiration(infraction["expires_at"]) or "Expired" + else: + remaining = "Inactive" + + if infraction["expires_at"] is None: + expires = "*Permanent*" + else: + date_from = datetime.strptime(created, time.INFRACTION_FORMAT) + expires = time.format_infraction_with_duration(infraction["expires_at"], date_from) + + lines = textwrap.dedent(f""" + {"**===============**" if active else "==============="} + Status: {"__**Active**__" if active else "Inactive"} + User: {self.bot.get_user(user_id)} (`{user_id}`) + Type: **{infraction["type"]}** + Shadow: {hidden} + Created: {created} + Expires: {expires} + Remaining: {remaining} + Actor: {actor.mention if actor else actor_id} + ID: `{infraction["id"]}` + Reason: {infraction["reason"] or "*None*"} + {"**===============**" if active else "==============="} + """) + + return lines.strip() + + # endregion + + # This cannot be static (must have a __func__ attribute). + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators inside moderator channels to invoke the commands in this cog.""" + checks = [ + with_role_check(ctx, *constants.MODERATION_ROLES), + in_whitelist_check( + ctx, + channels=constants.MODERATION_CHANNELS, + categories=[constants.Categories.modmail], + redirect=None, + fail_silently=True, + ) + ] + return all(checks) + + # This cannot be static (must have a __func__ attribute). + async def cog_command_error(self, ctx: Context, error: Exception) -> None: + """Send a notification to the invoking context on a Union failure.""" + if isinstance(error, commands.BadUnionArgument): + if discord.User in error.converters: + await ctx.send(str(error.errors[0])) + error.handled = True diff --git a/bot/cogs/moderation/infraction/scheduler.py b/bot/cogs/moderation/infraction/scheduler.py new file mode 100644 index 000000000..b3d27fe76 --- /dev/null +++ b/bot/cogs/moderation/infraction/scheduler.py @@ -0,0 +1,463 @@ +import logging +import textwrap +import typing as t +from abc import abstractmethod +from datetime import datetime +from gettext import ngettext + +import dateutil.parser +import discord +from discord.ext.commands import Context + +from bot import constants +from bot.api import ResponseCodeError +from bot.bot import Bot +from bot.cogs.moderation.modlog import ModLog +from bot.constants import Colours, STAFF_CHANNELS +from bot.utils import time +from bot.utils.scheduling import Scheduler +from . import utils +from .utils import UserSnowflake + +log = logging.getLogger(__name__) + + +class InfractionScheduler: + """Handles the application, pardoning, and expiration of infractions.""" + + def __init__(self, bot: Bot, supported_infractions: t.Container[str]): + self.bot = bot + self.scheduler = Scheduler(self.__class__.__name__) + + self.bot.loop.create_task(self.reschedule_infractions(supported_infractions)) + + def cog_unload(self) -> None: + """Cancel scheduled tasks.""" + self.scheduler.cancel_all() + + @property + def mod_log(self) -> ModLog: + """Get the currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + async def reschedule_infractions(self, supported_infractions: t.Container[str]) -> None: + """Schedule expiration for previous infractions.""" + await self.bot.wait_until_guild_available() + + log.trace(f"Rescheduling infractions for {self.__class__.__name__}.") + + infractions = await self.bot.api_client.get( + 'bot/infractions', + params={'active': 'true'} + ) + for infraction in infractions: + if infraction["expires_at"] is not None and infraction["type"] in supported_infractions: + self.schedule_expiration(infraction) + + async def reapply_infraction( + self, + infraction: utils.Infraction, + apply_coro: t.Optional[t.Awaitable] + ) -> None: + """Reapply an infraction if it's still active or deactivate it if less than 60 sec left.""" + # Calculate the time remaining, in seconds, for the mute. + expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) + delta = (expiry - datetime.utcnow()).total_seconds() + + # Mark as inactive if less than a minute remains. + if delta < 60: + log.info( + "Infraction will be deactivated instead of re-applied " + "because less than 1 minute remains." + ) + await self.deactivate_infraction(infraction) + return + + # Allowing mod log since this is a passive action that should be logged. + await apply_coro + log.info(f"Re-applied {infraction['type']} to user {infraction['user']} upon rejoining.") + + async def apply_infraction( + self, + ctx: Context, + infraction: utils.Infraction, + user: UserSnowflake, + action_coro: t.Optional[t.Awaitable] = None + ) -> None: + """Apply an infraction to the user, log the infraction, and optionally notify the user.""" + infr_type = infraction["type"] + icon = utils.INFRACTION_ICONS[infr_type][0] + reason = infraction["reason"] + expiry = time.format_infraction_with_duration(infraction["expires_at"]) + id_ = infraction['id'] + + log.trace(f"Applying {infr_type} infraction #{id_} to {user}.") + + # Default values for the confirmation message and mod log. + confirm_msg = ":ok_hand: applied" + + # Specifying an expiry for a note or warning makes no sense. + if infr_type in ("note", "warning"): + expiry_msg = "" + else: + expiry_msg = f" until {expiry}" if expiry else " permanently" + + dm_result = "" + dm_log_text = "" + expiry_log_text = f"\nExpires: {expiry}" if expiry else "" + log_title = "applied" + log_content = None + failed = False + + # DM the user about the infraction if it's not a shadow/hidden infraction. + # This needs to happen before we apply the infraction, as the bot cannot + # send DMs to user that it doesn't share a guild with. If we were to + # apply kick/ban infractions first, this would mean that we'd make it + # impossible for us to deliver a DM. See python-discord/bot#982. + if not infraction["hidden"]: + dm_result = f"{constants.Emojis.failmail} " + dm_log_text = "\nDM: **Failed**" + + # Sometimes user is a discord.Object; make it a proper user. + try: + if not isinstance(user, (discord.Member, discord.User)): + user = await self.bot.fetch_user(user.id) + except discord.HTTPException as e: + log.error(f"Failed to DM {user.id}: could not fetch user (status {e.status})") + else: + # Accordingly display whether the user was successfully notified via DM. + if await utils.notify_infraction(user, infr_type, expiry, reason, icon): + dm_result = ":incoming_envelope: " + dm_log_text = "\nDM: Sent" + + end_msg = "" + if infraction["actor"] == self.bot.user.id: + log.trace( + f"Infraction #{id_} actor is bot; including the reason in the confirmation message." + ) + if reason: + end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})" + elif ctx.channel.id not in STAFF_CHANNELS: + log.trace( + f"Infraction #{id_} context is not in a staff channel; omitting infraction count." + ) + else: + log.trace(f"Fetching total infraction count for {user}.") + + infractions = await self.bot.api_client.get( + "bot/infractions", + params={"user__id": str(user.id)} + ) + total = len(infractions) + end_msg = f" ({total} infraction{ngettext('', 's', total)} total)" + + # Execute the necessary actions to apply the infraction on Discord. + if action_coro: + log.trace(f"Awaiting the infraction #{id_} application action coroutine.") + try: + await action_coro + if expiry: + # Schedule the expiration of the infraction. + self.schedule_expiration(infraction) + except discord.HTTPException as e: + # Accordingly display that applying the infraction failed. + confirm_msg = ":x: failed to apply" + expiry_msg = "" + log_content = ctx.author.mention + log_title = "failed to apply" + + log_msg = f"Failed to apply {infr_type} infraction #{id_} to {user}" + if isinstance(e, discord.Forbidden): + log.warning(f"{log_msg}: bot lacks permissions.") + else: + log.exception(log_msg) + failed = True + + if failed: + log.trace(f"Deleted infraction {infraction['id']} from database because applying infraction failed.") + try: + await self.bot.api_client.delete(f"bot/infractions/{id_}") + except ResponseCodeError as e: + confirm_msg += " and failed to delete" + log_title += " and failed to delete" + log.error(f"Deletion of {infr_type} infraction #{id_} failed with error code {e.status}.") + infr_message = "" + else: + infr_message = f" **{infr_type}** to {user.mention}{expiry_msg}{end_msg}" + + # Send a confirmation message to the invoking context. + log.trace(f"Sending infraction #{id_} confirmation message.") + await ctx.send(f"{dm_result}{confirm_msg}{infr_message}.") + + # Send a log message to the mod log. + log.trace(f"Sending apply mod log for infraction #{id_}.") + await self.mod_log.send_log_message( + icon_url=icon, + colour=Colours.soft_red, + title=f"Infraction {log_title}: {infr_type}", + thumbnail=user.avatar_url_as(static_format="png"), + text=textwrap.dedent(f""" + Member: {user.mention} (`{user.id}`) + Actor: {ctx.message.author}{dm_log_text}{expiry_log_text} + Reason: {reason} + """), + content=log_content, + footer=f"ID {infraction['id']}" + ) + + log.info(f"Applied {infr_type} infraction #{id_} to {user}.") + + async def pardon_infraction( + self, + ctx: Context, + infr_type: str, + user: UserSnowflake, + send_msg: bool = True + ) -> None: + """ + Prematurely end an infraction for a user and log the action in the mod log. + + If `send_msg` is True, then a pardoning confirmation message will be sent to + the context channel. Otherwise, no such message will be sent. + """ + log.trace(f"Pardoning {infr_type} infraction for {user}.") + + # Check the current active infraction + log.trace(f"Fetching active {infr_type} infractions for {user}.") + response = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': infr_type, + 'user__id': user.id + } + ) + + if not response: + log.debug(f"No active {infr_type} infraction found for {user}.") + await ctx.send(f":x: There's no active {infr_type} infraction for user {user.mention}.") + return + + # Deactivate the infraction and cancel its scheduled expiration task. + log_text = await self.deactivate_infraction(response[0], send_log=False) + + log_text["Member"] = f"{user.mention}(`{user.id}`)" + log_text["Actor"] = str(ctx.message.author) + log_content = None + id_ = response[0]['id'] + footer = f"ID: {id_}" + + # If multiple active infractions were found, mark them as inactive in the database + # and cancel their expiration tasks. + if len(response) > 1: + log.info( + f"Found more than one active {infr_type} infraction for user {user.id}; " + "deactivating the extra active infractions too." + ) + + footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}" + + log_note = f"Found multiple **active** {infr_type} infractions in the database." + if "Note" in log_text: + log_text["Note"] = f" {log_note}" + else: + log_text["Note"] = log_note + + # deactivate_infraction() is not called again because: + # 1. Discord cannot store multiple active bans or assign multiples of the same role + # 2. It would send a pardon DM for each active infraction, which is redundant + for infraction in response[1:]: + id_ = infraction['id'] + try: + # Mark infraction as inactive in the database. + await self.bot.api_client.patch( + f"bot/infractions/{id_}", + json={"active": False} + ) + except ResponseCodeError: + log.exception(f"Failed to deactivate infraction #{id_} ({infr_type})") + # This is simpler and cleaner than trying to concatenate all the errors. + log_text["Failure"] = "See bot's logs for details." + + # Cancel pending expiration task. + if infraction["expires_at"] is not None: + self.scheduler.cancel(infraction["id"]) + + # Accordingly display whether the user was successfully notified via DM. + dm_emoji = "" + if log_text.get("DM") == "Sent": + dm_emoji = ":incoming_envelope: " + elif "DM" in log_text: + dm_emoji = f"{constants.Emojis.failmail} " + + # Accordingly display whether the pardon failed. + if "Failure" in log_text: + confirm_msg = ":x: failed to pardon" + log_title = "pardon failed" + log_content = ctx.author.mention + + log.warning(f"Failed to pardon {infr_type} infraction #{id_} for {user}.") + else: + confirm_msg = ":ok_hand: pardoned" + log_title = "pardoned" + + log.info(f"Pardoned {infr_type} infraction #{id_} for {user}.") + + # Send a confirmation message to the invoking context. + if send_msg: + log.trace(f"Sending infraction #{id_} pardon confirmation message.") + await ctx.send( + f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. " + f"{log_text.get('Failure', '')}" + ) + + # Move reason to end of entry to avoid cutting out some keys + log_text["Reason"] = log_text.pop("Reason") + + # Send a log message to the mod log. + await self.mod_log.send_log_message( + icon_url=utils.INFRACTION_ICONS[infr_type][1], + colour=Colours.soft_green, + title=f"Infraction {log_title}: {infr_type}", + thumbnail=user.avatar_url_as(static_format="png"), + text="\n".join(f"{k}: {v}" for k, v in log_text.items()), + footer=footer, + content=log_content, + ) + + async def deactivate_infraction( + self, + infraction: utils.Infraction, + send_log: bool = True + ) -> t.Dict[str, str]: + """ + Deactivate an active infraction and return a dictionary of lines to send in a mod log. + + The infraction is removed from Discord, marked as inactive in the database, and has its + expiration task cancelled. If `send_log` is True, a mod log is sent for the + deactivation of the infraction. + + Infractions of unsupported types will raise a ValueError. + """ + guild = self.bot.get_guild(constants.Guild.id) + mod_role = guild.get_role(constants.Roles.moderators) + user_id = infraction["user"] + actor = infraction["actor"] + type_ = infraction["type"] + id_ = infraction["id"] + inserted_at = infraction["inserted_at"] + expiry = infraction["expires_at"] + + log.info(f"Marking infraction #{id_} as inactive (expired).") + + expiry = dateutil.parser.isoparse(expiry).replace(tzinfo=None) if expiry else None + created = time.format_infraction_with_duration(inserted_at, expiry) + + log_content = None + log_text = { + "Member": f"<@{user_id}>", + "Actor": str(self.bot.get_user(actor) or actor), + "Reason": infraction["reason"], + "Created": created, + } + + try: + log.trace("Awaiting the pardon action coroutine.") + returned_log = await self._pardon_action(infraction) + + if returned_log is not None: + log_text = {**log_text, **returned_log} # Merge the logs together + else: + raise ValueError( + f"Attempted to deactivate an unsupported infraction #{id_} ({type_})!" + ) + except discord.Forbidden: + log.warning(f"Failed to deactivate infraction #{id_} ({type_}): bot lacks permissions.") + log_text["Failure"] = "The bot lacks permissions to do this (role hierarchy?)" + log_content = mod_role.mention + except discord.HTTPException as e: + log.exception(f"Failed to deactivate infraction #{id_} ({type_})") + log_text["Failure"] = f"HTTPException with status {e.status} and code {e.code}." + log_content = mod_role.mention + + # Check if the user is currently being watched by Big Brother. + try: + log.trace(f"Determining if user {user_id} is currently being watched by Big Brother.") + + active_watch = await self.bot.api_client.get( + "bot/infractions", + params={ + "active": "true", + "type": "watch", + "user__id": user_id + } + ) + + log_text["Watching"] = "Yes" if active_watch else "No" + except ResponseCodeError: + log.exception(f"Failed to fetch watch status for user {user_id}") + log_text["Watching"] = "Unknown - failed to fetch watch status." + + try: + # Mark infraction as inactive in the database. + log.trace(f"Marking infraction #{id_} as inactive in the database.") + await self.bot.api_client.patch( + f"bot/infractions/{id_}", + json={"active": False} + ) + except ResponseCodeError as e: + log.exception(f"Failed to deactivate infraction #{id_} ({type_})") + log_line = f"API request failed with code {e.status}." + log_content = mod_role.mention + + # Append to an existing failure message if possible + if "Failure" in log_text: + log_text["Failure"] += f" {log_line}" + else: + log_text["Failure"] = log_line + + # Cancel the expiration task. + if infraction["expires_at"] is not None: + self.scheduler.cancel(infraction["id"]) + + # Send a log message to the mod log. + if send_log: + log_title = "expiration failed" if "Failure" in log_text else "expired" + + user = self.bot.get_user(user_id) + avatar = user.avatar_url_as(static_format="png") if user else None + + # Move reason to end so when reason is too long, this is not gonna cut out required items. + log_text["Reason"] = log_text.pop("Reason") + + log.trace(f"Sending deactivation mod log for infraction #{id_}.") + await self.mod_log.send_log_message( + icon_url=utils.INFRACTION_ICONS[type_][1], + colour=Colours.soft_green, + title=f"Infraction {log_title}: {type_}", + thumbnail=avatar, + text="\n".join(f"{k}: {v}" for k, v in log_text.items()), + footer=f"ID: {id_}", + content=log_content, + ) + + return log_text + + @abstractmethod + async def _pardon_action(self, infraction: utils.Infraction) -> t.Optional[t.Dict[str, str]]: + """ + Execute deactivation steps specific to the infraction's type and return a log dict. + + If an infraction type is unsupported, return None instead. + """ + raise NotImplementedError + + def schedule_expiration(self, infraction: utils.Infraction) -> None: + """ + Marks an infraction expired after the delay from time of scheduling to time of expiration. + + At the time of expiration, the infraction is marked as inactive on the website and the + expiration task is cancelled. + """ + expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) + self.scheduler.schedule_at(expiry, infraction["id"], self.deactivate_infraction(infraction)) diff --git a/bot/cogs/moderation/infraction/superstarify.py b/bot/cogs/moderation/infraction/superstarify.py new file mode 100644 index 000000000..867de815a --- /dev/null +++ b/bot/cogs/moderation/infraction/superstarify.py @@ -0,0 +1,239 @@ +import json +import logging +import random +import textwrap +import typing as t +from pathlib import Path + +from discord import Colour, Embed, Member +from discord.ext.commands import Cog, Context, command + +from bot import constants +from bot.bot import Bot +from bot.converters import Expiry +from bot.utils.checks import with_role_check +from bot.utils.time import format_infraction +from . import utils +from .scheduler import InfractionScheduler + +log = logging.getLogger(__name__) +NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#nickname-policy" + +with Path("bot/resources/stars.json").open(encoding="utf-8") as stars_file: + STAR_NAMES = json.load(stars_file) + + +class Superstarify(InfractionScheduler, Cog): + """A set of commands to moderate terrible nicknames.""" + + def __init__(self, bot: Bot): + super().__init__(bot, supported_infractions={"superstar"}) + + @Cog.listener() + async def on_member_update(self, before: Member, after: Member) -> None: + """Revert nickname edits if the user has an active superstarify infraction.""" + if before.display_name == after.display_name: + return # User didn't change their nickname. Abort! + + log.trace( + f"{before} ({before.display_name}) is trying to change their nickname to " + f"{after.display_name}. Checking if the user is in superstar-prison..." + ) + + active_superstarifies = await self.bot.api_client.get( + "bot/infractions", + params={ + "active": "true", + "type": "superstar", + "user__id": str(before.id) + } + ) + + if not active_superstarifies: + log.trace(f"{before} has no active superstar infractions.") + return + + infraction = active_superstarifies[0] + forced_nick = self.get_nick(infraction["id"], before.id) + if after.display_name == forced_nick: + return # Nick change was triggered by this event. Ignore. + + log.info( + f"{after.display_name} ({after.id}) tried to escape superstar prison. " + f"Changing the nick back to {before.display_name}." + ) + await after.edit( + nick=forced_nick, + reason=f"Superstarified member tried to escape the prison: {infraction['id']}" + ) + + notified = await utils.notify_infraction( + user=after, + infr_type="Superstarify", + expires_at=format_infraction(infraction["expires_at"]), + reason=( + "You have tried to change your nickname on the **Python Discord** server " + f"from **{before.display_name}** to **{after.display_name}**, but as you " + "are currently in superstar-prison, you do not have permission to do so." + ), + icon_url=utils.INFRACTION_ICONS["superstar"][0] + ) + + if not notified: + log.info("Failed to DM user about why they cannot change their nickname.") + + @Cog.listener() + async def on_member_join(self, member: Member) -> None: + """Reapply active superstar infractions for returning members.""" + active_superstarifies = await self.bot.api_client.get( + "bot/infractions", + params={ + "active": "true", + "type": "superstar", + "user__id": member.id + } + ) + + if active_superstarifies: + infraction = active_superstarifies[0] + action = member.edit( + nick=self.get_nick(infraction["id"], member.id), + reason=f"Superstarified member tried to escape the prison: {infraction['id']}" + ) + + await self.reapply_infraction(infraction, action) + + @command(name="superstarify", aliases=("force_nick", "star")) + async def superstarify( + self, + ctx: Context, + member: Member, + duration: Expiry, + *, + reason: str = None, + ) -> None: + """ + Temporarily force a random superstar name (like Taylor Swift) to be the user's nickname. + + 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. + + An optional reason can be provided. If no reason is given, the original name will be shown + in a generated reason. + """ + if await utils.get_active_infraction(ctx, member, "superstar"): + return + + # Post the infraction to the API + reason = reason or f"old nick: {member.display_name}" + infraction = await utils.post_infraction(ctx, member, "superstar", reason, duration, active=True) + id_ = infraction["id"] + + old_nick = member.display_name + forced_nick = self.get_nick(id_, member.id) + expiry_str = format_infraction(infraction["expires_at"]) + + # Apply the infraction and schedule the expiration task. + log.debug(f"Changing nickname of {member} to {forced_nick}.") + self.mod_log.ignore(constants.Event.member_update, member.id) + await member.edit(nick=forced_nick, reason=reason) + self.schedule_expiration(infraction) + + # Send a DM to the user to notify them of their new infraction. + await utils.notify_infraction( + user=member, + infr_type="Superstarify", + expires_at=expiry_str, + icon_url=utils.INFRACTION_ICONS["superstar"][0], + reason=f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL})." + ) + + # Send an embed with the infraction information to the invoking context. + log.trace(f"Sending superstar #{id_} embed.") + embed = Embed( + title="Congratulations!", + colour=constants.Colours.soft_orange, + description=( + f"Your previous nickname, **{old_nick}**, " + f"was so bad that we have decided to change it. " + f"Your new nickname will be **{forced_nick}**.\n\n" + f"You will be unable to change your nickname until **{expiry_str}**.\n\n" + "If you're confused by this, please read our " + f"[official nickname policy]({NICKNAME_POLICY_URL})." + ) + ) + await ctx.send(embed=embed) + + # Log to the mod log channel. + log.trace(f"Sending apply mod log for superstar #{id_}.") + await self.mod_log.send_log_message( + icon_url=utils.INFRACTION_ICONS["superstar"][0], + colour=Colour.gold(), + title="Member achieved superstardom", + thumbnail=member.avatar_url_as(static_format="png"), + text=textwrap.dedent(f""" + Member: {member.mention} (`{member.id}`) + Actor: {ctx.message.author} + Expires: {expiry_str} + Old nickname: `{old_nick}` + New nickname: `{forced_nick}` + Reason: {reason} + """), + footer=f"ID {id_}" + ) + + @command(name="unsuperstarify", aliases=("release_nick", "unstar")) + async def unsuperstarify(self, ctx: Context, member: Member) -> None: + """Remove the superstarify infraction and allow the user to change their nickname.""" + await self.pardon_infraction(ctx, "superstar", member) + + async def _pardon_action(self, infraction: utils.Infraction) -> t.Optional[t.Dict[str, str]]: + """Pardon a superstar infraction and return a log dict.""" + if infraction["type"] != "superstar": + return + + guild = self.bot.get_guild(constants.Guild.id) + user = guild.get_member(infraction["user"]) + + # Don't bother sending a notification if the user left the guild. + if not user: + log.debug( + "User left the guild and therefore won't be notified about superstar " + f"{infraction['id']} pardon." + ) + return {} + + # DM the user about the expiration. + notified = await utils.notify_pardon( + user=user, + title="You are no longer superstarified", + content="You may now change your nickname on the server.", + icon_url=utils.INFRACTION_ICONS["superstar"][1] + ) + + return { + "Member": f"{user.mention}(`{user.id}`)", + "DM": "Sent" if notified else "**Failed**" + } + + @staticmethod + def get_nick(infraction_id: int, member_id: int) -> str: + """Randomly select a nickname from the Superstarify nickname list.""" + log.trace(f"Choosing a random nickname for superstar #{infraction_id}.") + + rng = random.Random(str(infraction_id) + str(member_id)) + return rng.choice(STAR_NAMES) + + # This cannot be static (must have a __func__ attribute). + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + return with_role_check(ctx, *constants.MODERATION_ROLES) diff --git a/bot/cogs/moderation/infraction/utils.py b/bot/cogs/moderation/infraction/utils.py new file mode 100644 index 000000000..fb55287b6 --- /dev/null +++ b/bot/cogs/moderation/infraction/utils.py @@ -0,0 +1,201 @@ +import logging +import textwrap +import typing as t +from datetime import datetime + +import discord +from discord.ext.commands import Context + +from bot.api import ResponseCodeError +from bot.constants import Colours, Icons + +log = logging.getLogger(__name__) + +# apply icon, pardon icon +INFRACTION_ICONS = { + "ban": (Icons.user_ban, Icons.user_unban), + "kick": (Icons.sign_out, None), + "mute": (Icons.user_mute, Icons.user_unmute), + "note": (Icons.user_warn, None), + "superstar": (Icons.superstarify, Icons.unsuperstarify), + "warning": (Icons.user_warn, None), +} +RULES_URL = "https://pythondiscord.com/pages/rules" +APPEALABLE_INFRACTIONS = ("ban", "mute") + +# Type aliases +UserObject = t.Union[discord.Member, discord.User] +UserSnowflake = t.Union[UserObject, discord.Object] +Infraction = t.Dict[str, t.Union[str, int, bool]] + + +async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]: + """ + Create a new user in the database. + + Used when an infraction needs to be applied on a user absent in the guild. + """ + log.trace(f"Attempting to add user {user.id} to the database.") + + if not isinstance(user, (discord.Member, discord.User)): + log.debug("The user being added to the DB is not a Member or User object.") + + payload = { + 'discriminator': int(getattr(user, 'discriminator', 0)), + 'id': user.id, + 'in_guild': False, + 'name': getattr(user, 'name', 'Name unknown'), + 'roles': [] + } + + try: + response = await ctx.bot.api_client.post('bot/users', json=payload) + log.info(f"User {user.id} added to the DB.") + return response + except ResponseCodeError as e: + log.error(f"Failed to add user {user.id} to the DB. {e}") + await ctx.send(f":x: The attempt to add the user to the DB failed: status {e.status}") + + +async def post_infraction( + ctx: Context, + user: UserSnowflake, + infr_type: str, + reason: str, + expires_at: datetime = None, + hidden: bool = False, + active: bool = True +) -> t.Optional[dict]: + """Posts an infraction to the API.""" + log.trace(f"Posting {infr_type} infraction for {user} to the API.") + + payload = { + "actor": ctx.message.author.id, + "hidden": hidden, + "reason": reason, + "type": infr_type, + "user": user.id, + "active": active + } + if expires_at: + payload['expires_at'] = expires_at.isoformat() + + # Try to apply the infraction. If it fails because the user doesn't exist, try to add it. + for should_post_user in (True, False): + try: + response = await ctx.bot.api_client.post('bot/infractions', json=payload) + return response + except ResponseCodeError as e: + if e.status == 400 and 'user' in e.response_json: + # Only one attempt to add the user to the database, not two: + if not should_post_user or await post_user(ctx, user) is None: + return + else: + log.exception(f"Unexpected error while adding an infraction for {user}:") + await ctx.send(f":x: There was an error adding the infraction: status {e.status}.") + return + + +async def get_active_infraction( + ctx: Context, + user: UserSnowflake, + infr_type: str, + send_msg: bool = True +) -> t.Optional[dict]: + """ + Retrieves an active infraction of the given type for the user. + + If `send_msg` is True and the user has an active infraction matching the `infr_type` parameter, + then a message for the moderator will be sent to the context channel letting them know. + Otherwise, no message will be sent. + """ + log.trace(f"Checking if {user} has active infractions of type {infr_type}.") + + active_infractions = await ctx.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': infr_type, + 'user__id': str(user.id) + } + ) + if active_infractions: + # Checks to see if the moderator should be told there is an active infraction + if send_msg: + log.trace(f"{user} has active infractions of type {infr_type}.") + await ctx.send( + f":x: According to my records, this user already has a {infr_type} infraction. " + f"See infraction **#{active_infractions[0]['id']}**." + ) + return active_infractions[0] + else: + log.trace(f"{user} does not have active infractions of type {infr_type}.") + + +async def notify_infraction( + user: UserObject, + infr_type: str, + expires_at: t.Optional[str] = None, + reason: t.Optional[str] = None, + icon_url: str = Icons.token_removed +) -> bool: + """DM a user about their new infraction and return True if the DM is successful.""" + log.trace(f"Sending {user} a DM about their {infr_type} infraction.") + + text = textwrap.dedent(f""" + **Type:** {infr_type.capitalize()} + **Expires:** {expires_at or "N/A"} + **Reason:** {reason or "No reason provided."} + """) + + embed = discord.Embed( + description=textwrap.shorten(text, width=2048, placeholder="..."), + colour=Colours.soft_red + ) + + embed.set_author(name="Infraction information", icon_url=icon_url, url=RULES_URL) + embed.title = f"Please review our rules over at {RULES_URL}" + embed.url = RULES_URL + + if infr_type in APPEALABLE_INFRACTIONS: + embed.set_footer( + text="To appeal this infraction, send an e-mail to appeals@pythondiscord.com" + ) + + return await send_private_embed(user, embed) + + +async def notify_pardon( + user: UserObject, + title: str, + content: str, + icon_url: str = Icons.user_verified +) -> bool: + """DM a user about their pardoned infraction and return True if the DM is successful.""" + log.trace(f"Sending {user} a DM about their pardoned infraction.") + + embed = discord.Embed( + description=content, + colour=Colours.soft_green + ) + + embed.set_author(name=title, icon_url=icon_url) + + return await send_private_embed(user, embed) + + +async def send_private_embed(user: UserObject, embed: discord.Embed) -> bool: + """ + A helper method for sending an embed to a user's DMs. + + Returns a boolean indicator of DM success. + """ + try: + await user.send(embed=embed) + return True + except (discord.HTTPException, discord.Forbidden, discord.NotFound): + log.debug( + f"Infraction-related information could not be sent to user {user} ({user.id}). " + "The user either could not be retrieved or probably disabled their DMs." + ) + return False diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py deleted file mode 100644 index 8df642428..000000000 --- a/bot/cogs/moderation/infractions.py +++ /dev/null @@ -1,370 +0,0 @@ -import logging -import textwrap -import typing as t - -import discord -from discord import Member -from discord.ext import commands -from discord.ext.commands import Context, command - -from bot import constants -from bot.bot import Bot -from bot.constants import Event -from bot.converters import Expiry, FetchedMember -from bot.decorators import respect_role_hierarchy -from bot.utils.checks import with_role_check -from . import utils -from .scheduler import InfractionScheduler -from .utils import UserSnowflake - -log = logging.getLogger(__name__) - - -class Infractions(InfractionScheduler, commands.Cog): - """Apply and pardon infractions on users for moderation purposes.""" - - category = "Moderation" - category_description = "Server moderation tools." - - def __init__(self, bot: Bot): - super().__init__(bot, supported_infractions={"ban", "kick", "mute", "note", "warning"}) - - self.category = "Moderation" - self._muted_role = discord.Object(constants.Roles.muted) - - @commands.Cog.listener() - async def on_member_join(self, member: Member) -> None: - """Reapply active mute infractions for returning members.""" - active_mutes = await self.bot.api_client.get( - "bot/infractions", - params={ - "active": "true", - "type": "mute", - "user__id": member.id - } - ) - - if active_mutes: - reason = f"Re-applying active mute: {active_mutes[0]['id']}" - action = member.add_roles(self._muted_role, reason=reason) - - await self.reapply_infraction(active_mutes[0], action) - - # region: Permanent infractions - - @command() - async def warn(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: - """Warn a user for the given reason.""" - infraction = await utils.post_infraction(ctx, user, "warning", reason, active=False) - if infraction is None: - return - - await self.apply_infraction(ctx, infraction, user) - - @command() - async def kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: - """Kick a user for the given reason.""" - await self.apply_kick(ctx, user, reason) - - @command() - async def ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: - """Permanently ban a user for the given reason and stop watching them with Big Brother.""" - await self.apply_ban(ctx, user, reason) - - # endregion - # region: Temporary infractions - - @command(aliases=["mute"]) - async def tempmute(self, ctx: Context, user: Member, duration: Expiry, *, reason: t.Optional[str] = None) -> None: - """ - Temporarily mute a user for the given reason and duration. - - 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. - """ - await self.apply_mute(ctx, user, reason, expires_at=duration) - - @command() - async def tempban( - self, - ctx: Context, - user: FetchedMember, - duration: Expiry, - *, - reason: t.Optional[str] = None - ) -> None: - """ - Temporarily ban a user for the given reason and duration. - - 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. - """ - await self.apply_ban(ctx, user, reason, expires_at=duration) - - # endregion - # region: Permanent shadow infractions - - @command(hidden=True) - async def note(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: - """Create a private note for a user with the given reason without notifying the user.""" - infraction = await utils.post_infraction(ctx, user, "note", reason, hidden=True, active=False) - if infraction is None: - return - - await self.apply_infraction(ctx, infraction, user) - - @command(hidden=True, aliases=['shadowkick', 'skick']) - async def shadow_kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: - """Kick a user for the given reason without notifying the user.""" - await self.apply_kick(ctx, user, reason, hidden=True) - - @command(hidden=True, aliases=['shadowban', 'sban']) - async def shadow_ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: - """Permanently ban a user for the given reason without notifying the user.""" - await self.apply_ban(ctx, user, reason, hidden=True) - - # endregion - # region: Temporary shadow infractions - - @command(hidden=True, aliases=["shadowtempmute, stempmute", "shadowmute", "smute"]) - async def shadow_tempmute( - self, ctx: Context, - user: Member, - duration: Expiry, - *, - reason: t.Optional[str] = None - ) -> None: - """ - Temporarily mute a user for the given reason and duration without notifying the user. - - 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. - """ - await self.apply_mute(ctx, user, reason, expires_at=duration, hidden=True) - - @command(hidden=True, aliases=["shadowtempban, stempban"]) - async def shadow_tempban( - self, - ctx: Context, - user: FetchedMember, - duration: Expiry, - *, - reason: t.Optional[str] = None - ) -> None: - """ - Temporarily ban a user for the given reason and duration without notifying the user. - - 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. - """ - await self.apply_ban(ctx, user, reason, expires_at=duration, hidden=True) - - # endregion - # region: Remove infractions (un- commands) - - @command() - async def unmute(self, ctx: Context, user: FetchedMember) -> None: - """Prematurely end the active mute infraction for the user.""" - await self.pardon_infraction(ctx, "mute", user) - - @command() - async def unban(self, ctx: Context, user: FetchedMember) -> None: - """Prematurely end the active ban infraction for the user.""" - await self.pardon_infraction(ctx, "ban", user) - - # endregion - # region: Base apply functions - - async def apply_mute(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None: - """Apply a mute infraction with kwargs passed to `post_infraction`.""" - if await utils.get_active_infraction(ctx, user, "mute"): - return - - infraction = await utils.post_infraction(ctx, user, "mute", reason, active=True, **kwargs) - if infraction is None: - return - - self.mod_log.ignore(Event.member_update, user.id) - - async def action() -> None: - await user.add_roles(self._muted_role, reason=reason) - - log.trace(f"Attempting to kick {user} from voice because they've been muted.") - await user.move_to(None, reason=reason) - - await self.apply_infraction(ctx, infraction, user, action()) - - @respect_role_hierarchy() - async def apply_kick(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None: - """Apply a kick infraction with kwargs passed to `post_infraction`.""" - infraction = await utils.post_infraction(ctx, user, "kick", reason, active=False, **kwargs) - if infraction is None: - return - - self.mod_log.ignore(Event.member_remove, user.id) - - if reason: - reason = textwrap.shorten(reason, width=512, placeholder="...") - - action = user.kick(reason=reason) - await self.apply_infraction(ctx, infraction, user, action) - - @respect_role_hierarchy() - async def apply_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None: - """ - Apply a ban infraction with kwargs passed to `post_infraction`. - - Will also remove the banned user from the Big Brother watch list if applicable. - """ - # In the case of a permanent ban, we don't need get_active_infractions to tell us if one is active - is_temporary = kwargs.get("expires_at") is not None - active_infraction = await utils.get_active_infraction(ctx, user, "ban", is_temporary) - - if active_infraction: - if is_temporary: - log.trace("Tempban ignored as it cannot overwrite an active ban.") - return - - if active_infraction.get('expires_at') is None: - log.trace("Permaban already exists, notify.") - await ctx.send(f":x: User is already permanently banned (#{active_infraction['id']}).") - return - - log.trace("Old tempban is being replaced by new permaban.") - await self.pardon_infraction(ctx, "ban", user, is_temporary) - - infraction = await utils.post_infraction(ctx, user, "ban", reason, active=True, **kwargs) - if infraction is None: - return - - self.mod_log.ignore(Event.member_remove, user.id) - - if reason: - reason = textwrap.shorten(reason, width=512, placeholder="...") - - action = ctx.guild.ban(user, reason=reason, delete_message_days=0) - await self.apply_infraction(ctx, infraction, user, action) - - if infraction.get('expires_at') is not None: - log.trace(f"Ban isn't permanent; user {user} won't be unwatched by Big Brother.") - return - - bb_cog = self.bot.get_cog("Big Brother") - if not bb_cog: - log.error(f"Big Brother cog not loaded; perma-banned user {user} won't be unwatched.") - return - - log.trace(f"Big Brother cog loaded; attempting to unwatch perma-banned user {user}.") - - bb_reason = "User has been permanently banned from the server. Automatically removed." - await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False) - - # endregion - # region: Base pardon functions - - async def pardon_mute(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]: - """Remove a user's muted role, DM them a notification, and return a log dict.""" - user = guild.get_member(user_id) - log_text = {} - - if user: - # Remove the muted role. - self.mod_log.ignore(Event.member_update, user.id) - await user.remove_roles(self._muted_role, reason=reason) - - # DM the user about the expiration. - notified = await utils.notify_pardon( - user=user, - title="You have been unmuted", - content="You may now send messages in the server.", - icon_url=utils.INFRACTION_ICONS["mute"][1] - ) - - log_text["Member"] = f"{user.mention}(`{user.id}`)" - log_text["DM"] = "Sent" if notified else "**Failed**" - else: - log.info(f"Failed to unmute user {user_id}: user not found") - log_text["Failure"] = "User was not found in the guild." - - return log_text - - async def pardon_ban(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]: - """Remove a user's ban on the Discord guild and return a log dict.""" - user = discord.Object(user_id) - log_text = {} - - self.mod_log.ignore(Event.member_unban, user_id) - - try: - await guild.unban(user, reason=reason) - except discord.NotFound: - log.info(f"Failed to unban user {user_id}: no active ban found on Discord") - log_text["Note"] = "No active ban found on Discord." - - return log_text - - async def _pardon_action(self, infraction: utils.Infraction) -> t.Optional[t.Dict[str, str]]: - """ - Execute deactivation steps specific to the infraction's type and return a log dict. - - If an infraction type is unsupported, return None instead. - """ - guild = self.bot.get_guild(constants.Guild.id) - user_id = infraction["user"] - reason = f"Infraction #{infraction['id']} expired or was pardoned." - - if infraction["type"] == "mute": - return await self.pardon_mute(user_id, guild, reason) - elif infraction["type"] == "ban": - return await self.pardon_ban(user_id, guild, reason) - - # endregion - - # This cannot be static (must have a __func__ attribute). - def cog_check(self, ctx: Context) -> bool: - """Only allow moderators to invoke the commands in this cog.""" - return with_role_check(ctx, *constants.MODERATION_ROLES) - - # This cannot be static (must have a __func__ attribute). - async def cog_command_error(self, ctx: Context, error: Exception) -> None: - """Send a notification to the invoking context on a Union failure.""" - if isinstance(error, commands.BadUnionArgument): - if discord.User in error.converters or discord.Member in error.converters: - await ctx.send(str(error.errors[0])) - error.handled = True diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py deleted file mode 100644 index 672bb0e9c..000000000 --- a/bot/cogs/moderation/management.py +++ /dev/null @@ -1,305 +0,0 @@ -import logging -import textwrap -import typing as t -from datetime import datetime - -import discord -from discord.ext import commands -from discord.ext.commands import Context - -from bot import constants -from bot.bot import Bot -from bot.converters import Expiry, InfractionSearchQuery, allowed_strings, proxy_user -from bot.pagination import LinePaginator -from bot.utils import time -from bot.utils.checks import in_whitelist_check, with_role_check -from . import utils -from .infractions import Infractions -from .modlog import ModLog - -log = logging.getLogger(__name__) - - -class ModManagement(commands.Cog): - """Management of infractions.""" - - category = "Moderation" - - def __init__(self, bot: Bot): - self.bot = bot - - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - @property - def infractions_cog(self) -> Infractions: - """Get currently loaded Infractions cog instance.""" - return self.bot.get_cog("Infractions") - - # region: Edit infraction commands - - @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True) - async def infraction_group(self, ctx: Context) -> None: - """Infraction manipulation commands.""" - await ctx.send_help(ctx.command) - - @infraction_group.command(name='edit') - async def infraction_edit( - self, - ctx: Context, - infraction_id: t.Union[int, allowed_strings("l", "last", "recent")], # noqa: F821 - duration: t.Union[Expiry, allowed_strings("p", "permanent"), None], # noqa: F821 - *, - reason: str = None - ) -> None: - """ - Edit the duration and/or the reason of an infraction. - - Durations are relative to the time of updating and should be appended with a unit of time. - Units (∗case-sensitive): - \u2003`y` - years - \u2003`m` - months∗ - \u2003`w` - weeks - \u2003`d` - days - \u2003`h` - hours - \u2003`M` - minutes∗ - \u2003`s` - seconds - - Use "l", "last", or "recent" as the infraction ID to specify that the most recent infraction - authored by the command invoker should be edited. - - Use "p" or "permanent" to mark the infraction as permanent. Alternatively, an ISO 8601 - timestamp can be provided for the duration. - """ - if duration is None and reason is None: - # Unlike UserInputError, the error handler will show a specified message for BadArgument - raise commands.BadArgument("Neither a new expiry nor a new reason was specified.") - - # Retrieve the previous infraction for its information. - if isinstance(infraction_id, str): - params = { - "actor__id": ctx.author.id, - "ordering": "-inserted_at" - } - infractions = await self.bot.api_client.get("bot/infractions", params=params) - - if infractions: - old_infraction = infractions[0] - infraction_id = old_infraction["id"] - else: - await ctx.send( - ":x: Couldn't find most recent infraction; you have never given an infraction." - ) - return - else: - old_infraction = await self.bot.api_client.get(f"bot/infractions/{infraction_id}") - - request_data = {} - confirm_messages = [] - log_text = "" - - if duration is not None and not old_infraction['active']: - if reason is None: - await ctx.send(":x: Cannot edit the expiration of an expired infraction.") - return - confirm_messages.append("expiry unchanged (infraction already expired)") - elif isinstance(duration, str): - request_data['expires_at'] = None - confirm_messages.append("marked as permanent") - elif duration is not None: - request_data['expires_at'] = duration.isoformat() - expiry = time.format_infraction_with_duration(request_data['expires_at']) - confirm_messages.append(f"set to expire on {expiry}") - else: - confirm_messages.append("expiry unchanged") - - if reason: - request_data['reason'] = reason - confirm_messages.append("set a new reason") - log_text += f""" - Previous reason: {old_infraction['reason']} - New reason: {reason} - """.rstrip() - else: - confirm_messages.append("reason unchanged") - - # Update the infraction - new_infraction = await self.bot.api_client.patch( - f'bot/infractions/{infraction_id}', - json=request_data, - ) - - # Re-schedule infraction if the expiration has been updated - if 'expires_at' in request_data: - # A scheduled task should only exist if the old infraction wasn't permanent - if old_infraction['expires_at']: - self.infractions_cog.scheduler.cancel(new_infraction['id']) - - # If the infraction was not marked as permanent, schedule a new expiration task - if request_data['expires_at']: - self.infractions_cog.schedule_expiration(new_infraction) - - log_text += f""" - Previous expiry: {old_infraction['expires_at'] or "Permanent"} - New expiry: {new_infraction['expires_at'] or "Permanent"} - """.rstrip() - - changes = ' & '.join(confirm_messages) - await ctx.send(f":ok_hand: Updated infraction #{infraction_id}: {changes}") - - # Get information about the infraction's user - user_id = new_infraction['user'] - user = ctx.guild.get_member(user_id) - - if user: - user_text = f"{user.mention} (`{user.id}`)" - thumbnail = user.avatar_url_as(static_format="png") - else: - user_text = f"`{user_id}`" - thumbnail = None - - # The infraction's actor - actor_id = new_infraction['actor'] - actor = ctx.guild.get_member(actor_id) or f"`{actor_id}`" - - await self.mod_log.send_log_message( - icon_url=constants.Icons.pencil, - colour=discord.Colour.blurple(), - title="Infraction edited", - thumbnail=thumbnail, - text=textwrap.dedent(f""" - Member: {user_text} - Actor: {actor} - Edited by: {ctx.message.author}{log_text} - """) - ) - - # endregion - # region: Search infractions - - @infraction_group.group(name="search", invoke_without_command=True) - async def infraction_search_group(self, ctx: Context, query: InfractionSearchQuery) -> None: - """Searches for infractions in the database.""" - if isinstance(query, discord.User): - await ctx.invoke(self.search_user, query) - else: - await ctx.invoke(self.search_reason, query) - - @infraction_search_group.command(name="user", aliases=("member", "id")) - async def search_user(self, ctx: Context, user: t.Union[discord.User, proxy_user]) -> None: - """Search for infractions by member.""" - infraction_list = await self.bot.api_client.get( - 'bot/infractions', - params={'user__id': str(user.id)} - ) - embed = discord.Embed( - title=f"Infractions for {user} ({len(infraction_list)} total)", - colour=discord.Colour.orange() - ) - await self.send_infraction_list(ctx, embed, infraction_list) - - @infraction_search_group.command(name="reason", aliases=("match", "regex", "re")) - async def search_reason(self, ctx: Context, reason: str) -> None: - """Search for infractions by their reason. Use Re2 for matching.""" - infraction_list = await self.bot.api_client.get( - 'bot/infractions', - params={'search': reason} - ) - embed = discord.Embed( - title=f"Infractions matching `{reason}` ({len(infraction_list)} total)", - colour=discord.Colour.orange() - ) - await self.send_infraction_list(ctx, embed, infraction_list) - - # endregion - # region: Utility functions - - async def send_infraction_list( - self, - ctx: Context, - embed: discord.Embed, - infractions: t.Iterable[utils.Infraction] - ) -> None: - """Send a paginated embed of infractions for the specified user.""" - if not infractions: - await ctx.send(":warning: No infractions could be found for that query.") - return - - lines = tuple( - self.infraction_to_string(infraction) - for infraction in infractions - ) - - await LinePaginator.paginate( - lines, - ctx=ctx, - embed=embed, - empty=True, - max_lines=3, - max_size=1000 - ) - - def infraction_to_string(self, infraction: utils.Infraction) -> str: - """Convert the infraction object to a string representation.""" - actor_id = infraction["actor"] - guild = self.bot.get_guild(constants.Guild.id) - actor = guild.get_member(actor_id) - active = infraction["active"] - user_id = infraction["user"] - hidden = infraction["hidden"] - created = time.format_infraction(infraction["inserted_at"]) - - if active: - remaining = time.until_expiration(infraction["expires_at"]) or "Expired" - else: - remaining = "Inactive" - - if infraction["expires_at"] is None: - expires = "*Permanent*" - else: - date_from = datetime.strptime(created, time.INFRACTION_FORMAT) - expires = time.format_infraction_with_duration(infraction["expires_at"], date_from) - - lines = textwrap.dedent(f""" - {"**===============**" if active else "==============="} - Status: {"__**Active**__" if active else "Inactive"} - User: {self.bot.get_user(user_id)} (`{user_id}`) - Type: **{infraction["type"]}** - Shadow: {hidden} - Created: {created} - Expires: {expires} - Remaining: {remaining} - Actor: {actor.mention if actor else actor_id} - ID: `{infraction["id"]}` - Reason: {infraction["reason"] or "*None*"} - {"**===============**" if active else "==============="} - """) - - return lines.strip() - - # endregion - - # This cannot be static (must have a __func__ attribute). - def cog_check(self, ctx: Context) -> bool: - """Only allow moderators inside moderator channels to invoke the commands in this cog.""" - checks = [ - with_role_check(ctx, *constants.MODERATION_ROLES), - in_whitelist_check( - ctx, - channels=constants.MODERATION_CHANNELS, - categories=[constants.Categories.modmail], - redirect=None, - fail_silently=True, - ) - ] - return all(checks) - - # This cannot be static (must have a __func__ attribute). - async def cog_command_error(self, ctx: Context, error: Exception) -> None: - """Send a notification to the invoking context on a Union failure.""" - if isinstance(error, commands.BadUnionArgument): - if discord.User in error.converters: - await ctx.send(str(error.errors[0])) - error.handled = True diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py deleted file mode 100644 index 75028d851..000000000 --- a/bot/cogs/moderation/scheduler.py +++ /dev/null @@ -1,463 +0,0 @@ -import logging -import textwrap -import typing as t -from abc import abstractmethod -from datetime import datetime -from gettext import ngettext - -import dateutil.parser -import discord -from discord.ext.commands import Context - -from bot import constants -from bot.api import ResponseCodeError -from bot.bot import Bot -from bot.constants import Colours, STAFF_CHANNELS -from bot.utils import time -from bot.utils.scheduling import Scheduler -from . import utils -from .modlog import ModLog -from .utils import UserSnowflake - -log = logging.getLogger(__name__) - - -class InfractionScheduler: - """Handles the application, pardoning, and expiration of infractions.""" - - def __init__(self, bot: Bot, supported_infractions: t.Container[str]): - self.bot = bot - self.scheduler = Scheduler(self.__class__.__name__) - - self.bot.loop.create_task(self.reschedule_infractions(supported_infractions)) - - def cog_unload(self) -> None: - """Cancel scheduled tasks.""" - self.scheduler.cancel_all() - - @property - def mod_log(self) -> ModLog: - """Get the currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - async def reschedule_infractions(self, supported_infractions: t.Container[str]) -> None: - """Schedule expiration for previous infractions.""" - await self.bot.wait_until_guild_available() - - log.trace(f"Rescheduling infractions for {self.__class__.__name__}.") - - infractions = await self.bot.api_client.get( - 'bot/infractions', - params={'active': 'true'} - ) - for infraction in infractions: - if infraction["expires_at"] is not None and infraction["type"] in supported_infractions: - self.schedule_expiration(infraction) - - async def reapply_infraction( - self, - infraction: utils.Infraction, - apply_coro: t.Optional[t.Awaitable] - ) -> None: - """Reapply an infraction if it's still active or deactivate it if less than 60 sec left.""" - # Calculate the time remaining, in seconds, for the mute. - expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) - delta = (expiry - datetime.utcnow()).total_seconds() - - # Mark as inactive if less than a minute remains. - if delta < 60: - log.info( - "Infraction will be deactivated instead of re-applied " - "because less than 1 minute remains." - ) - await self.deactivate_infraction(infraction) - return - - # Allowing mod log since this is a passive action that should be logged. - await apply_coro - log.info(f"Re-applied {infraction['type']} to user {infraction['user']} upon rejoining.") - - async def apply_infraction( - self, - ctx: Context, - infraction: utils.Infraction, - user: UserSnowflake, - action_coro: t.Optional[t.Awaitable] = None - ) -> None: - """Apply an infraction to the user, log the infraction, and optionally notify the user.""" - infr_type = infraction["type"] - icon = utils.INFRACTION_ICONS[infr_type][0] - reason = infraction["reason"] - expiry = time.format_infraction_with_duration(infraction["expires_at"]) - id_ = infraction['id'] - - log.trace(f"Applying {infr_type} infraction #{id_} to {user}.") - - # Default values for the confirmation message and mod log. - confirm_msg = ":ok_hand: applied" - - # Specifying an expiry for a note or warning makes no sense. - if infr_type in ("note", "warning"): - expiry_msg = "" - else: - expiry_msg = f" until {expiry}" if expiry else " permanently" - - dm_result = "" - dm_log_text = "" - expiry_log_text = f"\nExpires: {expiry}" if expiry else "" - log_title = "applied" - log_content = None - failed = False - - # DM the user about the infraction if it's not a shadow/hidden infraction. - # This needs to happen before we apply the infraction, as the bot cannot - # send DMs to user that it doesn't share a guild with. If we were to - # apply kick/ban infractions first, this would mean that we'd make it - # impossible for us to deliver a DM. See python-discord/bot#982. - if not infraction["hidden"]: - dm_result = f"{constants.Emojis.failmail} " - dm_log_text = "\nDM: **Failed**" - - # Sometimes user is a discord.Object; make it a proper user. - try: - if not isinstance(user, (discord.Member, discord.User)): - user = await self.bot.fetch_user(user.id) - except discord.HTTPException as e: - log.error(f"Failed to DM {user.id}: could not fetch user (status {e.status})") - else: - # Accordingly display whether the user was successfully notified via DM. - if await utils.notify_infraction(user, infr_type, expiry, reason, icon): - dm_result = ":incoming_envelope: " - dm_log_text = "\nDM: Sent" - - end_msg = "" - if infraction["actor"] == self.bot.user.id: - log.trace( - f"Infraction #{id_} actor is bot; including the reason in the confirmation message." - ) - if reason: - end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})" - elif ctx.channel.id not in STAFF_CHANNELS: - log.trace( - f"Infraction #{id_} context is not in a staff channel; omitting infraction count." - ) - else: - log.trace(f"Fetching total infraction count for {user}.") - - infractions = await self.bot.api_client.get( - "bot/infractions", - params={"user__id": str(user.id)} - ) - total = len(infractions) - end_msg = f" ({total} infraction{ngettext('', 's', total)} total)" - - # Execute the necessary actions to apply the infraction on Discord. - if action_coro: - log.trace(f"Awaiting the infraction #{id_} application action coroutine.") - try: - await action_coro - if expiry: - # Schedule the expiration of the infraction. - self.schedule_expiration(infraction) - except discord.HTTPException as e: - # Accordingly display that applying the infraction failed. - confirm_msg = ":x: failed to apply" - expiry_msg = "" - log_content = ctx.author.mention - log_title = "failed to apply" - - log_msg = f"Failed to apply {infr_type} infraction #{id_} to {user}" - if isinstance(e, discord.Forbidden): - log.warning(f"{log_msg}: bot lacks permissions.") - else: - log.exception(log_msg) - failed = True - - if failed: - log.trace(f"Deleted infraction {infraction['id']} from database because applying infraction failed.") - try: - await self.bot.api_client.delete(f"bot/infractions/{id_}") - except ResponseCodeError as e: - confirm_msg += " and failed to delete" - log_title += " and failed to delete" - log.error(f"Deletion of {infr_type} infraction #{id_} failed with error code {e.status}.") - infr_message = "" - else: - infr_message = f" **{infr_type}** to {user.mention}{expiry_msg}{end_msg}" - - # Send a confirmation message to the invoking context. - log.trace(f"Sending infraction #{id_} confirmation message.") - await ctx.send(f"{dm_result}{confirm_msg}{infr_message}.") - - # Send a log message to the mod log. - log.trace(f"Sending apply mod log for infraction #{id_}.") - await self.mod_log.send_log_message( - icon_url=icon, - colour=Colours.soft_red, - title=f"Infraction {log_title}: {infr_type}", - thumbnail=user.avatar_url_as(static_format="png"), - text=textwrap.dedent(f""" - Member: {user.mention} (`{user.id}`) - Actor: {ctx.message.author}{dm_log_text}{expiry_log_text} - Reason: {reason} - """), - content=log_content, - footer=f"ID {infraction['id']}" - ) - - log.info(f"Applied {infr_type} infraction #{id_} to {user}.") - - async def pardon_infraction( - self, - ctx: Context, - infr_type: str, - user: UserSnowflake, - send_msg: bool = True - ) -> None: - """ - Prematurely end an infraction for a user and log the action in the mod log. - - If `send_msg` is True, then a pardoning confirmation message will be sent to - the context channel. Otherwise, no such message will be sent. - """ - log.trace(f"Pardoning {infr_type} infraction for {user}.") - - # Check the current active infraction - log.trace(f"Fetching active {infr_type} infractions for {user}.") - response = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': infr_type, - 'user__id': user.id - } - ) - - if not response: - log.debug(f"No active {infr_type} infraction found for {user}.") - await ctx.send(f":x: There's no active {infr_type} infraction for user {user.mention}.") - return - - # Deactivate the infraction and cancel its scheduled expiration task. - log_text = await self.deactivate_infraction(response[0], send_log=False) - - log_text["Member"] = f"{user.mention}(`{user.id}`)" - log_text["Actor"] = str(ctx.message.author) - log_content = None - id_ = response[0]['id'] - footer = f"ID: {id_}" - - # If multiple active infractions were found, mark them as inactive in the database - # and cancel their expiration tasks. - if len(response) > 1: - log.info( - f"Found more than one active {infr_type} infraction for user {user.id}; " - "deactivating the extra active infractions too." - ) - - footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}" - - log_note = f"Found multiple **active** {infr_type} infractions in the database." - if "Note" in log_text: - log_text["Note"] = f" {log_note}" - else: - log_text["Note"] = log_note - - # deactivate_infraction() is not called again because: - # 1. Discord cannot store multiple active bans or assign multiples of the same role - # 2. It would send a pardon DM for each active infraction, which is redundant - for infraction in response[1:]: - id_ = infraction['id'] - try: - # Mark infraction as inactive in the database. - await self.bot.api_client.patch( - f"bot/infractions/{id_}", - json={"active": False} - ) - except ResponseCodeError: - log.exception(f"Failed to deactivate infraction #{id_} ({infr_type})") - # This is simpler and cleaner than trying to concatenate all the errors. - log_text["Failure"] = "See bot's logs for details." - - # Cancel pending expiration task. - if infraction["expires_at"] is not None: - self.scheduler.cancel(infraction["id"]) - - # Accordingly display whether the user was successfully notified via DM. - dm_emoji = "" - if log_text.get("DM") == "Sent": - dm_emoji = ":incoming_envelope: " - elif "DM" in log_text: - dm_emoji = f"{constants.Emojis.failmail} " - - # Accordingly display whether the pardon failed. - if "Failure" in log_text: - confirm_msg = ":x: failed to pardon" - log_title = "pardon failed" - log_content = ctx.author.mention - - log.warning(f"Failed to pardon {infr_type} infraction #{id_} for {user}.") - else: - confirm_msg = ":ok_hand: pardoned" - log_title = "pardoned" - - log.info(f"Pardoned {infr_type} infraction #{id_} for {user}.") - - # Send a confirmation message to the invoking context. - if send_msg: - log.trace(f"Sending infraction #{id_} pardon confirmation message.") - await ctx.send( - f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. " - f"{log_text.get('Failure', '')}" - ) - - # Move reason to end of entry to avoid cutting out some keys - log_text["Reason"] = log_text.pop("Reason") - - # Send a log message to the mod log. - await self.mod_log.send_log_message( - icon_url=utils.INFRACTION_ICONS[infr_type][1], - colour=Colours.soft_green, - title=f"Infraction {log_title}: {infr_type}", - thumbnail=user.avatar_url_as(static_format="png"), - text="\n".join(f"{k}: {v}" for k, v in log_text.items()), - footer=footer, - content=log_content, - ) - - async def deactivate_infraction( - self, - infraction: utils.Infraction, - send_log: bool = True - ) -> t.Dict[str, str]: - """ - Deactivate an active infraction and return a dictionary of lines to send in a mod log. - - The infraction is removed from Discord, marked as inactive in the database, and has its - expiration task cancelled. If `send_log` is True, a mod log is sent for the - deactivation of the infraction. - - Infractions of unsupported types will raise a ValueError. - """ - guild = self.bot.get_guild(constants.Guild.id) - mod_role = guild.get_role(constants.Roles.moderators) - user_id = infraction["user"] - actor = infraction["actor"] - type_ = infraction["type"] - id_ = infraction["id"] - inserted_at = infraction["inserted_at"] - expiry = infraction["expires_at"] - - log.info(f"Marking infraction #{id_} as inactive (expired).") - - expiry = dateutil.parser.isoparse(expiry).replace(tzinfo=None) if expiry else None - created = time.format_infraction_with_duration(inserted_at, expiry) - - log_content = None - log_text = { - "Member": f"<@{user_id}>", - "Actor": str(self.bot.get_user(actor) or actor), - "Reason": infraction["reason"], - "Created": created, - } - - try: - log.trace("Awaiting the pardon action coroutine.") - returned_log = await self._pardon_action(infraction) - - if returned_log is not None: - log_text = {**log_text, **returned_log} # Merge the logs together - else: - raise ValueError( - f"Attempted to deactivate an unsupported infraction #{id_} ({type_})!" - ) - except discord.Forbidden: - log.warning(f"Failed to deactivate infraction #{id_} ({type_}): bot lacks permissions.") - log_text["Failure"] = "The bot lacks permissions to do this (role hierarchy?)" - log_content = mod_role.mention - except discord.HTTPException as e: - log.exception(f"Failed to deactivate infraction #{id_} ({type_})") - log_text["Failure"] = f"HTTPException with status {e.status} and code {e.code}." - log_content = mod_role.mention - - # Check if the user is currently being watched by Big Brother. - try: - log.trace(f"Determining if user {user_id} is currently being watched by Big Brother.") - - active_watch = await self.bot.api_client.get( - "bot/infractions", - params={ - "active": "true", - "type": "watch", - "user__id": user_id - } - ) - - log_text["Watching"] = "Yes" if active_watch else "No" - except ResponseCodeError: - log.exception(f"Failed to fetch watch status for user {user_id}") - log_text["Watching"] = "Unknown - failed to fetch watch status." - - try: - # Mark infraction as inactive in the database. - log.trace(f"Marking infraction #{id_} as inactive in the database.") - await self.bot.api_client.patch( - f"bot/infractions/{id_}", - json={"active": False} - ) - except ResponseCodeError as e: - log.exception(f"Failed to deactivate infraction #{id_} ({type_})") - log_line = f"API request failed with code {e.status}." - log_content = mod_role.mention - - # Append to an existing failure message if possible - if "Failure" in log_text: - log_text["Failure"] += f" {log_line}" - else: - log_text["Failure"] = log_line - - # Cancel the expiration task. - if infraction["expires_at"] is not None: - self.scheduler.cancel(infraction["id"]) - - # Send a log message to the mod log. - if send_log: - log_title = "expiration failed" if "Failure" in log_text else "expired" - - user = self.bot.get_user(user_id) - avatar = user.avatar_url_as(static_format="png") if user else None - - # Move reason to end so when reason is too long, this is not gonna cut out required items. - log_text["Reason"] = log_text.pop("Reason") - - log.trace(f"Sending deactivation mod log for infraction #{id_}.") - await self.mod_log.send_log_message( - icon_url=utils.INFRACTION_ICONS[type_][1], - colour=Colours.soft_green, - title=f"Infraction {log_title}: {type_}", - thumbnail=avatar, - text="\n".join(f"{k}: {v}" for k, v in log_text.items()), - footer=f"ID: {id_}", - content=log_content, - ) - - return log_text - - @abstractmethod - async def _pardon_action(self, infraction: utils.Infraction) -> t.Optional[t.Dict[str, str]]: - """ - Execute deactivation steps specific to the infraction's type and return a log dict. - - If an infraction type is unsupported, return None instead. - """ - raise NotImplementedError - - def schedule_expiration(self, infraction: utils.Infraction) -> None: - """ - Marks an infraction expired after the delay from time of scheduling to time of expiration. - - At the time of expiration, the infraction is marked as inactive on the website and the - expiration task is cancelled. - """ - expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) - self.scheduler.schedule_at(expiry, infraction["id"], self.deactivate_infraction(infraction)) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py deleted file mode 100644 index 867de815a..000000000 --- a/bot/cogs/moderation/superstarify.py +++ /dev/null @@ -1,239 +0,0 @@ -import json -import logging -import random -import textwrap -import typing as t -from pathlib import Path - -from discord import Colour, Embed, Member -from discord.ext.commands import Cog, Context, command - -from bot import constants -from bot.bot import Bot -from bot.converters import Expiry -from bot.utils.checks import with_role_check -from bot.utils.time import format_infraction -from . import utils -from .scheduler import InfractionScheduler - -log = logging.getLogger(__name__) -NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#nickname-policy" - -with Path("bot/resources/stars.json").open(encoding="utf-8") as stars_file: - STAR_NAMES = json.load(stars_file) - - -class Superstarify(InfractionScheduler, Cog): - """A set of commands to moderate terrible nicknames.""" - - def __init__(self, bot: Bot): - super().__init__(bot, supported_infractions={"superstar"}) - - @Cog.listener() - async def on_member_update(self, before: Member, after: Member) -> None: - """Revert nickname edits if the user has an active superstarify infraction.""" - if before.display_name == after.display_name: - return # User didn't change their nickname. Abort! - - log.trace( - f"{before} ({before.display_name}) is trying to change their nickname to " - f"{after.display_name}. Checking if the user is in superstar-prison..." - ) - - active_superstarifies = await self.bot.api_client.get( - "bot/infractions", - params={ - "active": "true", - "type": "superstar", - "user__id": str(before.id) - } - ) - - if not active_superstarifies: - log.trace(f"{before} has no active superstar infractions.") - return - - infraction = active_superstarifies[0] - forced_nick = self.get_nick(infraction["id"], before.id) - if after.display_name == forced_nick: - return # Nick change was triggered by this event. Ignore. - - log.info( - f"{after.display_name} ({after.id}) tried to escape superstar prison. " - f"Changing the nick back to {before.display_name}." - ) - await after.edit( - nick=forced_nick, - reason=f"Superstarified member tried to escape the prison: {infraction['id']}" - ) - - notified = await utils.notify_infraction( - user=after, - infr_type="Superstarify", - expires_at=format_infraction(infraction["expires_at"]), - reason=( - "You have tried to change your nickname on the **Python Discord** server " - f"from **{before.display_name}** to **{after.display_name}**, but as you " - "are currently in superstar-prison, you do not have permission to do so." - ), - icon_url=utils.INFRACTION_ICONS["superstar"][0] - ) - - if not notified: - log.info("Failed to DM user about why they cannot change their nickname.") - - @Cog.listener() - async def on_member_join(self, member: Member) -> None: - """Reapply active superstar infractions for returning members.""" - active_superstarifies = await self.bot.api_client.get( - "bot/infractions", - params={ - "active": "true", - "type": "superstar", - "user__id": member.id - } - ) - - if active_superstarifies: - infraction = active_superstarifies[0] - action = member.edit( - nick=self.get_nick(infraction["id"], member.id), - reason=f"Superstarified member tried to escape the prison: {infraction['id']}" - ) - - await self.reapply_infraction(infraction, action) - - @command(name="superstarify", aliases=("force_nick", "star")) - async def superstarify( - self, - ctx: Context, - member: Member, - duration: Expiry, - *, - reason: str = None, - ) -> None: - """ - Temporarily force a random superstar name (like Taylor Swift) to be the user's nickname. - - 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. - - An optional reason can be provided. If no reason is given, the original name will be shown - in a generated reason. - """ - if await utils.get_active_infraction(ctx, member, "superstar"): - return - - # Post the infraction to the API - reason = reason or f"old nick: {member.display_name}" - infraction = await utils.post_infraction(ctx, member, "superstar", reason, duration, active=True) - id_ = infraction["id"] - - old_nick = member.display_name - forced_nick = self.get_nick(id_, member.id) - expiry_str = format_infraction(infraction["expires_at"]) - - # Apply the infraction and schedule the expiration task. - log.debug(f"Changing nickname of {member} to {forced_nick}.") - self.mod_log.ignore(constants.Event.member_update, member.id) - await member.edit(nick=forced_nick, reason=reason) - self.schedule_expiration(infraction) - - # Send a DM to the user to notify them of their new infraction. - await utils.notify_infraction( - user=member, - infr_type="Superstarify", - expires_at=expiry_str, - icon_url=utils.INFRACTION_ICONS["superstar"][0], - reason=f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL})." - ) - - # Send an embed with the infraction information to the invoking context. - log.trace(f"Sending superstar #{id_} embed.") - embed = Embed( - title="Congratulations!", - colour=constants.Colours.soft_orange, - description=( - f"Your previous nickname, **{old_nick}**, " - f"was so bad that we have decided to change it. " - f"Your new nickname will be **{forced_nick}**.\n\n" - f"You will be unable to change your nickname until **{expiry_str}**.\n\n" - "If you're confused by this, please read our " - f"[official nickname policy]({NICKNAME_POLICY_URL})." - ) - ) - await ctx.send(embed=embed) - - # Log to the mod log channel. - log.trace(f"Sending apply mod log for superstar #{id_}.") - await self.mod_log.send_log_message( - icon_url=utils.INFRACTION_ICONS["superstar"][0], - colour=Colour.gold(), - title="Member achieved superstardom", - thumbnail=member.avatar_url_as(static_format="png"), - text=textwrap.dedent(f""" - Member: {member.mention} (`{member.id}`) - Actor: {ctx.message.author} - Expires: {expiry_str} - Old nickname: `{old_nick}` - New nickname: `{forced_nick}` - Reason: {reason} - """), - footer=f"ID {id_}" - ) - - @command(name="unsuperstarify", aliases=("release_nick", "unstar")) - async def unsuperstarify(self, ctx: Context, member: Member) -> None: - """Remove the superstarify infraction and allow the user to change their nickname.""" - await self.pardon_infraction(ctx, "superstar", member) - - async def _pardon_action(self, infraction: utils.Infraction) -> t.Optional[t.Dict[str, str]]: - """Pardon a superstar infraction and return a log dict.""" - if infraction["type"] != "superstar": - return - - guild = self.bot.get_guild(constants.Guild.id) - user = guild.get_member(infraction["user"]) - - # Don't bother sending a notification if the user left the guild. - if not user: - log.debug( - "User left the guild and therefore won't be notified about superstar " - f"{infraction['id']} pardon." - ) - return {} - - # DM the user about the expiration. - notified = await utils.notify_pardon( - user=user, - title="You are no longer superstarified", - content="You may now change your nickname on the server.", - icon_url=utils.INFRACTION_ICONS["superstar"][1] - ) - - return { - "Member": f"{user.mention}(`{user.id}`)", - "DM": "Sent" if notified else "**Failed**" - } - - @staticmethod - def get_nick(infraction_id: int, member_id: int) -> str: - """Randomly select a nickname from the Superstarify nickname list.""" - log.trace(f"Choosing a random nickname for superstar #{infraction_id}.") - - rng = random.Random(str(infraction_id) + str(member_id)) - return rng.choice(STAR_NAMES) - - # This cannot be static (must have a __func__ attribute). - def cog_check(self, ctx: Context) -> bool: - """Only allow moderators to invoke the commands in this cog.""" - return with_role_check(ctx, *constants.MODERATION_ROLES) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py deleted file mode 100644 index fb55287b6..000000000 --- a/bot/cogs/moderation/utils.py +++ /dev/null @@ -1,201 +0,0 @@ -import logging -import textwrap -import typing as t -from datetime import datetime - -import discord -from discord.ext.commands import Context - -from bot.api import ResponseCodeError -from bot.constants import Colours, Icons - -log = logging.getLogger(__name__) - -# apply icon, pardon icon -INFRACTION_ICONS = { - "ban": (Icons.user_ban, Icons.user_unban), - "kick": (Icons.sign_out, None), - "mute": (Icons.user_mute, Icons.user_unmute), - "note": (Icons.user_warn, None), - "superstar": (Icons.superstarify, Icons.unsuperstarify), - "warning": (Icons.user_warn, None), -} -RULES_URL = "https://pythondiscord.com/pages/rules" -APPEALABLE_INFRACTIONS = ("ban", "mute") - -# Type aliases -UserObject = t.Union[discord.Member, discord.User] -UserSnowflake = t.Union[UserObject, discord.Object] -Infraction = t.Dict[str, t.Union[str, int, bool]] - - -async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]: - """ - Create a new user in the database. - - Used when an infraction needs to be applied on a user absent in the guild. - """ - log.trace(f"Attempting to add user {user.id} to the database.") - - if not isinstance(user, (discord.Member, discord.User)): - log.debug("The user being added to the DB is not a Member or User object.") - - payload = { - 'discriminator': int(getattr(user, 'discriminator', 0)), - 'id': user.id, - 'in_guild': False, - 'name': getattr(user, 'name', 'Name unknown'), - 'roles': [] - } - - try: - response = await ctx.bot.api_client.post('bot/users', json=payload) - log.info(f"User {user.id} added to the DB.") - return response - except ResponseCodeError as e: - log.error(f"Failed to add user {user.id} to the DB. {e}") - await ctx.send(f":x: The attempt to add the user to the DB failed: status {e.status}") - - -async def post_infraction( - ctx: Context, - user: UserSnowflake, - infr_type: str, - reason: str, - expires_at: datetime = None, - hidden: bool = False, - active: bool = True -) -> t.Optional[dict]: - """Posts an infraction to the API.""" - log.trace(f"Posting {infr_type} infraction for {user} to the API.") - - payload = { - "actor": ctx.message.author.id, - "hidden": hidden, - "reason": reason, - "type": infr_type, - "user": user.id, - "active": active - } - if expires_at: - payload['expires_at'] = expires_at.isoformat() - - # Try to apply the infraction. If it fails because the user doesn't exist, try to add it. - for should_post_user in (True, False): - try: - response = await ctx.bot.api_client.post('bot/infractions', json=payload) - return response - except ResponseCodeError as e: - if e.status == 400 and 'user' in e.response_json: - # Only one attempt to add the user to the database, not two: - if not should_post_user or await post_user(ctx, user) is None: - return - else: - log.exception(f"Unexpected error while adding an infraction for {user}:") - await ctx.send(f":x: There was an error adding the infraction: status {e.status}.") - return - - -async def get_active_infraction( - ctx: Context, - user: UserSnowflake, - infr_type: str, - send_msg: bool = True -) -> t.Optional[dict]: - """ - Retrieves an active infraction of the given type for the user. - - If `send_msg` is True and the user has an active infraction matching the `infr_type` parameter, - then a message for the moderator will be sent to the context channel letting them know. - Otherwise, no message will be sent. - """ - log.trace(f"Checking if {user} has active infractions of type {infr_type}.") - - active_infractions = await ctx.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': infr_type, - 'user__id': str(user.id) - } - ) - if active_infractions: - # Checks to see if the moderator should be told there is an active infraction - if send_msg: - log.trace(f"{user} has active infractions of type {infr_type}.") - await ctx.send( - f":x: According to my records, this user already has a {infr_type} infraction. " - f"See infraction **#{active_infractions[0]['id']}**." - ) - return active_infractions[0] - else: - log.trace(f"{user} does not have active infractions of type {infr_type}.") - - -async def notify_infraction( - user: UserObject, - infr_type: str, - expires_at: t.Optional[str] = None, - reason: t.Optional[str] = None, - icon_url: str = Icons.token_removed -) -> bool: - """DM a user about their new infraction and return True if the DM is successful.""" - log.trace(f"Sending {user} a DM about their {infr_type} infraction.") - - text = textwrap.dedent(f""" - **Type:** {infr_type.capitalize()} - **Expires:** {expires_at or "N/A"} - **Reason:** {reason or "No reason provided."} - """) - - embed = discord.Embed( - description=textwrap.shorten(text, width=2048, placeholder="..."), - colour=Colours.soft_red - ) - - embed.set_author(name="Infraction information", icon_url=icon_url, url=RULES_URL) - embed.title = f"Please review our rules over at {RULES_URL}" - embed.url = RULES_URL - - if infr_type in APPEALABLE_INFRACTIONS: - embed.set_footer( - text="To appeal this infraction, send an e-mail to appeals@pythondiscord.com" - ) - - return await send_private_embed(user, embed) - - -async def notify_pardon( - user: UserObject, - title: str, - content: str, - icon_url: str = Icons.user_verified -) -> bool: - """DM a user about their pardoned infraction and return True if the DM is successful.""" - log.trace(f"Sending {user} a DM about their pardoned infraction.") - - embed = discord.Embed( - description=content, - colour=Colours.soft_green - ) - - embed.set_author(name=title, icon_url=icon_url) - - return await send_private_embed(user, embed) - - -async def send_private_embed(user: UserObject, embed: discord.Embed) -> bool: - """ - A helper method for sending an embed to a user's DMs. - - Returns a boolean indicator of DM success. - """ - try: - await user.send(embed=embed) - return True - except (discord.HTTPException, discord.Forbidden, discord.NotFound): - log.debug( - f"Infraction-related information could not be sent to user {user} ({user.id}). " - "The user either could not be retrieved or probably disabled their DMs." - ) - return False diff --git a/bot/cogs/moderation/verification.py b/bot/cogs/moderation/verification.py new file mode 100644 index 000000000..ae156cf70 --- /dev/null +++ b/bot/cogs/moderation/verification.py @@ -0,0 +1,191 @@ +import logging +from contextlib import suppress + +from discord import Colour, Forbidden, Message, NotFound, Object +from discord.ext.commands import Cog, Context, command + +from bot import constants +from bot.bot import Bot +from bot.cogs.moderation import ModLog +from bot.decorators import in_whitelist, without_role +from bot.utils.checks import InWhitelistCheckFailure, without_role_check + +log = logging.getLogger(__name__) + +WELCOME_MESSAGE = f""" +Hello! Welcome to the server, and thanks for verifying yourself! + +For your records, these are the documents you accepted: + +`1)` Our rules, here: +`2)` Our privacy policy, here: - you can find information on how to have \ +your information removed here as well. + +Feel free to review them at any point! + +Additionally, if you'd like to receive notifications for the announcements \ +we post in <#{constants.Channels.announcements}> +from time to time, you can send `!subscribe` to <#{constants.Channels.bot_commands}> at any time \ +to assign yourself the **Announcements** role. We'll mention this role every time we make an announcement. + +If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to \ +<#{constants.Channels.bot_commands}>. +""" + +BOT_MESSAGE_DELETE_DELAY = 10 + + +class Verification(Cog): + """User verification and role self-management.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @property + def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + @Cog.listener() + async def on_message(self, message: Message) -> None: + """Check new message event for messages to the checkpoint channel & process.""" + if message.channel.id != constants.Channels.verification: + return # Only listen for #checkpoint messages + + if message.author.bot: + # They're a bot, delete their message after the delay. + await message.delete(delay=BOT_MESSAGE_DELETE_DELAY) + return + + # if a user mentions a role or guild member + # alert the mods in mod-alerts channel + if message.mentions or message.role_mentions: + log.debug( + f"{message.author} mentioned one or more users " + f"and/or roles in {message.channel.name}" + ) + + embed_text = ( + f"{message.author.mention} sent a message in " + f"{message.channel.mention} that contained user and/or role mentions." + f"\n\n**Original message:**\n>>> {message.content}" + ) + + # Send pretty mod log embed to mod-alerts + await self.mod_log.send_log_message( + icon_url=constants.Icons.filtering, + colour=Colour(constants.Colours.soft_red), + title=f"User/Role mentioned in {message.channel.name}", + text=embed_text, + thumbnail=message.author.avatar_url_as(static_format="png"), + channel_id=constants.Channels.mod_alerts, + ) + + ctx: Context = await self.bot.get_context(message) + if ctx.command is not None and ctx.command.name == "accept": + return + + if any(r.id == constants.Roles.verified for r in ctx.author.roles): + log.info( + f"{ctx.author} posted '{ctx.message.content}' " + "in the verification channel, but is already verified." + ) + return + + log.debug( + f"{ctx.author} posted '{ctx.message.content}' in the verification " + "channel. We are providing instructions how to verify." + ) + await ctx.send( + f"{ctx.author.mention} Please type `!accept` to verify that you accept our rules, " + f"and gain access to the rest of the server.", + delete_after=20 + ) + + log.trace(f"Deleting the message posted by {ctx.author}") + with suppress(NotFound): + await ctx.message.delete() + + @command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True) + @without_role(constants.Roles.verified) + @in_whitelist(channels=(constants.Channels.verification,)) + async def accept_command(self, ctx: Context, *_) -> None: # We don't actually care about the args + """Accept our rules and gain access to the rest of the server.""" + log.debug(f"{ctx.author} called !accept. Assigning the 'Developer' role.") + await ctx.author.add_roles(Object(constants.Roles.verified), reason="Accepted the rules") + try: + await ctx.author.send(WELCOME_MESSAGE) + except Forbidden: + log.info(f"Sending welcome message failed for {ctx.author}.") + finally: + log.trace(f"Deleting accept message by {ctx.author}.") + with suppress(NotFound): + self.mod_log.ignore(constants.Event.message_delete, ctx.message.id) + await ctx.message.delete() + + @command(name='subscribe') + @in_whitelist(channels=(constants.Channels.bot_commands,)) + async def subscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args + """Subscribe to announcement notifications by assigning yourself the role.""" + has_role = False + + for role in ctx.author.roles: + if role.id == constants.Roles.announcements: + has_role = True + break + + if has_role: + await ctx.send(f"{ctx.author.mention} You're already subscribed!") + return + + log.debug(f"{ctx.author} called !subscribe. Assigning the 'Announcements' role.") + await ctx.author.add_roles(Object(constants.Roles.announcements), reason="Subscribed to announcements") + + log.trace(f"Deleting the message posted by {ctx.author}.") + + await ctx.send( + f"{ctx.author.mention} Subscribed to <#{constants.Channels.announcements}> notifications.", + ) + + @command(name='unsubscribe') + @in_whitelist(channels=(constants.Channels.bot_commands,)) + async def unsubscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args + """Unsubscribe from announcement notifications by removing the role from yourself.""" + has_role = False + + for role in ctx.author.roles: + if role.id == constants.Roles.announcements: + has_role = True + break + + if not has_role: + await ctx.send(f"{ctx.author.mention} You're already unsubscribed!") + return + + log.debug(f"{ctx.author} called !unsubscribe. Removing the 'Announcements' role.") + await ctx.author.remove_roles(Object(constants.Roles.announcements), reason="Unsubscribed from announcements") + + log.trace(f"Deleting the message posted by {ctx.author}.") + + await ctx.send( + f"{ctx.author.mention} Unsubscribed from <#{constants.Channels.announcements}> notifications." + ) + + # This cannot be static (must have a __func__ attribute). + async def cog_command_error(self, ctx: Context, error: Exception) -> None: + """Check for & ignore any InWhitelistCheckFailure.""" + if isinstance(error, InWhitelistCheckFailure): + error.handled = True + + @staticmethod + def bot_check(ctx: Context) -> bool: + """Block any command within the verification channel that is not !accept.""" + if ctx.channel.id == constants.Channels.verification and without_role_check(ctx, *constants.MODERATION_ROLES): + return ctx.command.name == "accept" + else: + return True + + +def setup(bot: Bot) -> None: + """Load the Verification cog.""" + bot.add_cog(Verification(bot)) diff --git a/bot/cogs/moderation/watchchannels/__init__.py b/bot/cogs/moderation/watchchannels/__init__.py new file mode 100644 index 000000000..69d118df6 --- /dev/null +++ b/bot/cogs/moderation/watchchannels/__init__.py @@ -0,0 +1,9 @@ +from bot.bot import Bot +from .bigbrother import BigBrother +from .talentpool import TalentPool + + +def setup(bot: Bot) -> None: + """Load the BigBrother and TalentPool cogs.""" + bot.add_cog(BigBrother(bot)) + bot.add_cog(TalentPool(bot)) diff --git a/bot/cogs/moderation/watchchannels/bigbrother.py b/bot/cogs/moderation/watchchannels/bigbrother.py new file mode 100644 index 000000000..0c72e88f7 --- /dev/null +++ b/bot/cogs/moderation/watchchannels/bigbrother.py @@ -0,0 +1,165 @@ +import logging +import textwrap +from collections import ChainMap + +from discord.ext.commands import Cog, Context, group + +from bot.bot import Bot +from bot.cogs.moderation.infraction.utils import post_infraction +from bot.constants import Channels, MODERATION_ROLES, Webhooks +from bot.converters import FetchedMember +from bot.decorators import with_role +from .watchchannel import WatchChannel + +log = logging.getLogger(__name__) + + +class BigBrother(WatchChannel, Cog, name="Big Brother"): + """Monitors users by relaying their messages to a watch channel to assist with moderation.""" + + def __init__(self, bot: Bot) -> None: + super().__init__( + bot, + destination=Channels.big_brother_logs, + webhook_id=Webhooks.big_brother, + api_endpoint='bot/infractions', + api_default_params={'active': 'true', 'type': 'watch', 'ordering': '-inserted_at'}, + logger=log + ) + + @group(name='bigbrother', aliases=('bb',), invoke_without_command=True) + @with_role(*MODERATION_ROLES) + async def bigbrother_group(self, ctx: Context) -> None: + """Monitors users by relaying their messages to the Big Brother watch channel.""" + await ctx.send_help(ctx.command) + + @bigbrother_group.command(name='watched', aliases=('all', 'list')) + @with_role(*MODERATION_ROLES) + async def watched_command( + self, ctx: Context, oldest_first: bool = False, update_cache: bool = True + ) -> None: + """ + Shows the users that are currently being monitored by Big Brother. + + The optional kwarg `oldest_first` can be used to order the list by oldest watched. + + The optional kwarg `update_cache` can be used to update the user + cache using the API before listing the users. + """ + await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) + + @bigbrother_group.command(name='oldest') + @with_role(*MODERATION_ROLES) + async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: + """ + Shows Big Brother monitored users ordered by oldest watched. + + The optional kwarg `update_cache` can be used to update the user + cache using the API before listing the users. + """ + await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) + + @bigbrother_group.command(name='watch', aliases=('w',)) + @with_role(*MODERATION_ROLES) + async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + """ + Relay messages sent by the given `user` to the `#big-brother` channel. + + A `reason` for adding the user to Big Brother is required and will be displayed + in the header when relaying messages of this user to the watchchannel. + """ + await self.apply_watch(ctx, user, reason) + + @bigbrother_group.command(name='unwatch', aliases=('uw',)) + @with_role(*MODERATION_ROLES) + async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + """Stop relaying messages by the given `user`.""" + await self.apply_unwatch(ctx, user, reason) + + async def apply_watch(self, ctx: Context, user: FetchedMember, reason: str) -> None: + """ + Add `user` to watched users and apply a watch infraction with `reason`. + + A message indicating the result of the operation is sent to `ctx`. + The message will include `user`'s previous watch infraction history, if it exists. + """ + if user.bot: + await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") + return + + if not await self.fetch_user_cache(): + await ctx.send(f":x: Updating the user cache failed, can't watch user {user}") + return + + if user.id in self.watched_users: + await ctx.send(f":x: {user} is already being watched.") + return + + response = await post_infraction(ctx, user, 'watch', reason, hidden=True, active=True) + + if response is not None: + self.watched_users[user.id] = response + msg = f":white_check_mark: Messages sent by {user} will now be relayed to Big Brother." + + history = await self.bot.api_client.get( + self.api_endpoint, + params={ + "user__id": str(user.id), + "active": "false", + 'type': 'watch', + 'ordering': '-inserted_at' + } + ) + + if len(history) > 1: + total = f"({len(history) // 2} previous infractions in total)" + end_reason = textwrap.shorten(history[0]["reason"], width=500, placeholder="...") + start_reason = f"Watched: {textwrap.shorten(history[1]['reason'], width=500, placeholder='...')}" + msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}\n\n{end_reason}```" + else: + msg = ":x: Failed to post the infraction: response was empty." + + await ctx.send(msg) + + async def apply_unwatch(self, ctx: Context, user: FetchedMember, reason: str, send_message: bool = True) -> None: + """ + Remove `user` from watched users and mark their infraction as inactive with `reason`. + + If `send_message` is True, a message indicating the result of the operation is sent to + `ctx`. + """ + active_watches = await self.bot.api_client.get( + self.api_endpoint, + params=ChainMap( + self.api_default_params, + {"user__id": str(user.id)} + ) + ) + if active_watches: + log.trace("Active watches for user found. Attempting to remove.") + [infraction] = active_watches + + await self.bot.api_client.patch( + f"{self.api_endpoint}/{infraction['id']}", + json={'active': False} + ) + + await post_infraction(ctx, user, 'watch', f"Unwatched: {reason}", hidden=True, active=False) + + self._remove_user(user.id) + + if not send_message: # Prevents a message being sent to the channel if part of a permanent ban + log.debug(f"Perma-banned user {user} was unwatched.") + return + log.trace("User is not banned. Sending message to channel") + message = f":white_check_mark: Messages sent by {user} will no longer be relayed." + + else: + log.trace("No active watches found for user.") + if not send_message: # Prevents a message being sent to the channel if part of a permanent ban + log.debug(f"{user} was not on the watch list; no removal necessary.") + return + log.trace("User is not perma banned. Send the error message.") + message = ":x: The specified user is currently not being watched." + + await ctx.send(message) diff --git a/bot/cogs/moderation/watchchannels/talentpool.py b/bot/cogs/moderation/watchchannels/talentpool.py new file mode 100644 index 000000000..89256e92e --- /dev/null +++ b/bot/cogs/moderation/watchchannels/talentpool.py @@ -0,0 +1,264 @@ +import logging +import textwrap +from collections import ChainMap + +from discord import Color, Embed, Member +from discord.ext.commands import Cog, Context, group + +from bot.api import ResponseCodeError +from bot.bot import Bot +from bot.constants import Channels, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks +from bot.converters import FetchedMember +from bot.decorators import with_role +from bot.pagination import LinePaginator +from bot.utils import time +from .watchchannel import WatchChannel + +log = logging.getLogger(__name__) + + +class TalentPool(WatchChannel, Cog, name="Talentpool"): + """Relays messages of helper candidates to a watch channel to observe them.""" + + def __init__(self, bot: Bot) -> None: + super().__init__( + bot, + destination=Channels.talent_pool, + webhook_id=Webhooks.talent_pool, + api_endpoint='bot/nominations', + api_default_params={'active': 'true', 'ordering': '-inserted_at'}, + logger=log, + ) + + @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True) + @with_role(*MODERATION_ROLES) + async def nomination_group(self, ctx: Context) -> None: + """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" + await ctx.send_help(ctx.command) + + @nomination_group.command(name='watched', aliases=('all', 'list')) + @with_role(*MODERATION_ROLES) + async def watched_command( + self, ctx: Context, oldest_first: bool = False, update_cache: bool = True + ) -> None: + """ + Shows the users that are currently being monitored in the talent pool. + + The optional kwarg `oldest_first` can be used to order the list by oldest nomination. + + The optional kwarg `update_cache` can be used to update the user + cache using the API before listing the users. + """ + await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) + + @nomination_group.command(name='oldest') + @with_role(*MODERATION_ROLES) + async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: + """ + Shows talent pool monitored users ordered by oldest nomination. + + The optional kwarg `update_cache` can be used to update the user + cache using the API before listing the users. + """ + await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) + + @nomination_group.command(name='watch', aliases=('w', 'add', 'a')) + @with_role(*STAFF_ROLES) + async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + """ + Relay messages sent by the given `user` to the `#talent-pool` channel. + + A `reason` for adding the user to the talent pool is required and will be displayed + in the header when relaying messages of this user to the channel. + """ + if user.bot: + await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") + return + + if isinstance(user, Member) and any(role.id in STAFF_ROLES for role in user.roles): + await ctx.send(":x: Nominating staff members, eh? Here's a cookie :cookie:") + return + + if not await self.fetch_user_cache(): + await ctx.send(f":x: Failed to update the user cache; can't add {user}") + return + + if user.id in self.watched_users: + await ctx.send(f":x: {user} is already being watched in the talent pool") + return + + # Manual request with `raise_for_status` as False because we want the actual response + session = self.bot.api_client.session + url = self.bot.api_client._url_for(self.api_endpoint) + kwargs = { + 'json': { + 'actor': ctx.author.id, + 'reason': reason, + 'user': user.id + }, + 'raise_for_status': False, + } + async with session.post(url, **kwargs) as resp: + response_data = await resp.json() + + if resp.status == 400 and response_data.get('user', False): + await ctx.send(":x: The specified user can't be found in the database tables") + return + else: + resp.raise_for_status() + + self.watched_users[user.id] = response_data + msg = f":white_check_mark: Messages sent by {user} will now be relayed to the talent pool channel" + + history = await self.bot.api_client.get( + self.api_endpoint, + params={ + "user__id": str(user.id), + "active": "false", + "ordering": "-inserted_at" + } + ) + + if history: + total = f"({len(history)} previous nominations in total)" + start_reason = f"Watched: {textwrap.shorten(history[0]['reason'], width=500, placeholder='...')}" + end_reason = f"Unwatched: {textwrap.shorten(history[0]['end_reason'], width=500, placeholder='...')}" + msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}\n\n{end_reason}```" + + await ctx.send(msg) + + @nomination_group.command(name='history', aliases=('info', 'search')) + @with_role(*MODERATION_ROLES) + async def history_command(self, ctx: Context, user: FetchedMember) -> None: + """Shows the specified user's nomination history.""" + result = await self.bot.api_client.get( + self.api_endpoint, + params={ + 'user__id': str(user.id), + 'ordering': "-active,-inserted_at" + } + ) + if not result: + await ctx.send(":warning: This user has never been nominated") + return + + embed = Embed( + title=f"Nominations for {user.display_name} `({user.id})`", + color=Color.blue() + ) + lines = [self._nomination_to_string(nomination) for nomination in result] + await LinePaginator.paginate( + lines, + ctx=ctx, + embed=embed, + empty=True, + max_lines=3, + max_size=1000 + ) + + @nomination_group.command(name='unwatch', aliases=('end', )) + @with_role(*MODERATION_ROLES) + async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + """ + Ends the active nomination of the specified user with the given reason. + + Providing a `reason` is required. + """ + active_nomination = await self.bot.api_client.get( + self.api_endpoint, + params=ChainMap( + self.api_default_params, + {"user__id": str(user.id)} + ) + ) + + if not active_nomination: + await ctx.send(":x: The specified user does not have an active nomination") + return + + [nomination] = active_nomination + await self.bot.api_client.patch( + f"{self.api_endpoint}/{nomination['id']}", + json={'end_reason': reason, 'active': False} + ) + await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed") + self._remove_user(user.id) + + @nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True) + @with_role(*MODERATION_ROLES) + async def nomination_edit_group(self, ctx: Context) -> None: + """Commands to edit nominations.""" + await ctx.send_help(ctx.command) + + @nomination_edit_group.command(name='reason') + @with_role(*MODERATION_ROLES) + async def edit_reason_command(self, ctx: Context, nomination_id: int, *, reason: str) -> None: + """ + Edits the reason/unnominate reason for the nomination with the given `id` depending on the status. + + If the nomination is active, the reason for nominating the user will be edited; + If the nomination is no longer active, the reason for ending the nomination will be edited instead. + """ + try: + nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}") + except ResponseCodeError as e: + if e.response.status == 404: + self.log.trace(f"Nomination API 404: Can't nomination with id {nomination_id}") + await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`") + return + else: + raise + + field = "reason" if nomination["active"] else "end_reason" + + self.log.trace(f"Changing {field} for nomination with id {nomination_id} to {reason}") + + await self.bot.api_client.patch( + f"{self.api_endpoint}/{nomination_id}", + json={field: reason} + ) + + await ctx.send(f":white_check_mark: Updated the {field} of the nomination!") + + def _nomination_to_string(self, nomination_object: dict) -> str: + """Creates a string representation of a nomination.""" + guild = self.bot.get_guild(Guild.id) + + actor_id = nomination_object["actor"] + actor = guild.get_member(actor_id) + + active = nomination_object["active"] + log.debug(active) + log.debug(type(nomination_object["inserted_at"])) + + start_date = time.format_infraction(nomination_object["inserted_at"]) + if active: + lines = textwrap.dedent( + f""" + =============== + Status: **Active** + Date: {start_date} + Actor: {actor.mention if actor else actor_id} + Reason: {nomination_object["reason"]} + Nomination ID: `{nomination_object["id"]}` + =============== + """ + ) + else: + end_date = time.format_infraction(nomination_object["ended_at"]) + lines = textwrap.dedent( + f""" + =============== + Status: Inactive + Date: {start_date} + Actor: {actor.mention if actor else actor_id} + Reason: {nomination_object["reason"]} + + End date: {end_date} + Unwatch reason: {nomination_object["end_reason"]} + Nomination ID: `{nomination_object["id"]}` + =============== + """ + ) + + return lines.strip() diff --git a/bot/cogs/moderation/watchchannels/watchchannel.py b/bot/cogs/moderation/watchchannels/watchchannel.py new file mode 100644 index 000000000..044077350 --- /dev/null +++ b/bot/cogs/moderation/watchchannels/watchchannel.py @@ -0,0 +1,348 @@ +import asyncio +import logging +import re +import textwrap +from abc import abstractmethod +from collections import defaultdict, deque +from dataclasses import dataclass +from typing import Optional + +import dateutil.parser +import discord +from discord import Color, DMChannel, Embed, HTTPException, Message, errors +from discord.ext.commands import Cog, Context + +from bot.api import ResponseCodeError +from bot.bot import Bot +from bot.cogs.moderation import ModLog +from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons +from bot.pagination import LinePaginator +from bot.utils import CogABCMeta, messages +from bot.utils.time import time_since + +log = logging.getLogger(__name__) + +URL_RE = re.compile(r"(https?://[^\s]+)") + + +@dataclass +class MessageHistory: + """Represents a watch channel's message history.""" + + last_author: Optional[int] = None + last_channel: Optional[int] = None + message_count: int = 0 + + +class WatchChannel(metaclass=CogABCMeta): + """ABC with functionality for relaying users' messages to a certain channel.""" + + @abstractmethod + def __init__( + self, + bot: Bot, + destination: int, + webhook_id: int, + api_endpoint: str, + api_default_params: dict, + logger: logging.Logger + ) -> None: + self.bot = bot + + self.destination = destination # E.g., Channels.big_brother_logs + self.webhook_id = webhook_id # E.g., Webhooks.big_brother + self.api_endpoint = api_endpoint # E.g., 'bot/infractions' + self.api_default_params = api_default_params # E.g., {'active': 'true', 'type': 'watch'} + self.log = logger # Logger of the child cog for a correct name in the logs + + self._consume_task = None + self.watched_users = defaultdict(dict) + self.message_queue = defaultdict(lambda: defaultdict(deque)) + self.consumption_queue = {} + self.retries = 5 + self.retry_delay = 10 + self.channel = None + self.webhook = None + self.message_history = MessageHistory() + + self._start = self.bot.loop.create_task(self.start_watchchannel()) + + @property + def modlog(self) -> ModLog: + """Provides access to the ModLog cog for alert purposes.""" + return self.bot.get_cog("ModLog") + + @property + def consuming_messages(self) -> bool: + """Checks if a consumption task is currently running.""" + if self._consume_task is None: + return False + + if self._consume_task.done(): + exc = self._consume_task.exception() + if exc: + self.log.exception( + "The message queue consume task has failed with:", + exc_info=exc + ) + return False + + return True + + async def start_watchchannel(self) -> None: + """Starts the watch channel by getting the channel, webhook, and user cache ready.""" + await self.bot.wait_until_guild_available() + + try: + self.channel = await self.bot.fetch_channel(self.destination) + except HTTPException: + self.log.exception(f"Failed to retrieve the text channel with id `{self.destination}`") + + try: + self.webhook = await self.bot.fetch_webhook(self.webhook_id) + except discord.HTTPException: + self.log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") + + if self.channel is None or self.webhook is None: + self.log.error("Failed to start the watch channel; unloading the cog.") + + message = textwrap.dedent( + f""" + An error occurred while loading the text channel or webhook. + + TextChannel: {"**Failed to load**" if self.channel is None else "Loaded successfully"} + Webhook: {"**Failed to load**" if self.webhook is None else "Loaded successfully"} + + The Cog has been unloaded. + """ + ) + + await self.modlog.send_log_message( + title=f"Error: Failed to initialize the {self.__class__.__name__} watch channel", + text=message, + ping_everyone=True, + icon_url=Icons.token_removed, + colour=Color.red() + ) + + self.bot.remove_cog(self.__class__.__name__) + return + + if not await self.fetch_user_cache(): + await self.modlog.send_log_message( + title=f"Warning: Failed to retrieve user cache for the {self.__class__.__name__} watch channel", + text="Could not retrieve the list of watched users from the API and messages will not be relayed.", + ping_everyone=True, + icon_url=Icons.token_removed, + colour=Color.red() + ) + + async def fetch_user_cache(self) -> bool: + """ + Fetches watched users from the API and updates the watched user cache accordingly. + + This function returns `True` if the update succeeded. + """ + try: + data = await self.bot.api_client.get(self.api_endpoint, params=self.api_default_params) + except ResponseCodeError as err: + self.log.exception("Failed to fetch the watched users from the API", exc_info=err) + return False + + self.watched_users = defaultdict(dict) + + for entry in data: + user_id = entry.pop('user') + self.watched_users[user_id] = entry + + return True + + @Cog.listener() + async def on_message(self, msg: Message) -> None: + """Queues up messages sent by watched users.""" + if msg.author.id in self.watched_users: + if not self.consuming_messages: + self._consume_task = self.bot.loop.create_task(self.consume_messages()) + + self.log.trace(f"Received message: {msg.content} ({len(msg.attachments)} attachments)") + self.message_queue[msg.author.id][msg.channel.id].append(msg) + + async def consume_messages(self, delay_consumption: bool = True) -> None: + """Consumes the message queues to log watched users' messages.""" + if delay_consumption: + self.log.trace(f"Sleeping {BigBrotherConfig.log_delay} seconds before consuming message queue") + await asyncio.sleep(BigBrotherConfig.log_delay) + + self.log.trace("Started consuming the message queue") + + # If the previous consumption Task failed, first consume the existing comsumption_queue + if not self.consumption_queue: + self.consumption_queue = self.message_queue.copy() + self.message_queue.clear() + + for user_channel_queues in self.consumption_queue.values(): + for channel_queue in user_channel_queues.values(): + while channel_queue: + msg = channel_queue.popleft() + + self.log.trace(f"Consuming message {msg.id} ({len(msg.attachments)} attachments)") + await self.relay_message(msg) + + self.consumption_queue.clear() + + if self.message_queue: + self.log.trace("Channel queue not empty: Continuing consuming queues") + self._consume_task = self.bot.loop.create_task(self.consume_messages(delay_consumption=False)) + else: + self.log.trace("Done consuming messages.") + + async def webhook_send( + self, + content: Optional[str] = None, + username: Optional[str] = None, + avatar_url: Optional[str] = None, + embed: Optional[Embed] = None, + ) -> None: + """Sends a message to the webhook with the specified kwargs.""" + username = messages.sub_clyde(username) + try: + await self.webhook.send(content=content, username=username, avatar_url=avatar_url, embed=embed) + except discord.HTTPException as exc: + self.log.exception( + "Failed to send a message to the webhook", + exc_info=exc + ) + + async def relay_message(self, msg: Message) -> None: + """Relays the message to the relevant watch channel.""" + limit = BigBrotherConfig.header_message_limit + + if ( + msg.author.id != self.message_history.last_author + or msg.channel.id != self.message_history.last_channel + or self.message_history.message_count >= limit + ): + self.message_history = MessageHistory(last_author=msg.author.id, last_channel=msg.channel.id) + + await self.send_header(msg) + + cleaned_content = msg.clean_content + + if cleaned_content: + # Put all non-media URLs in a code block to prevent embeds + media_urls = {embed.url for embed in msg.embeds if embed.type in ("image", "video")} + for url in URL_RE.findall(cleaned_content): + if url not in media_urls: + cleaned_content = cleaned_content.replace(url, f"`{url}`") + await self.webhook_send( + cleaned_content, + username=msg.author.display_name, + avatar_url=msg.author.avatar_url + ) + + if msg.attachments: + try: + await messages.send_attachments(msg, self.webhook) + except (errors.Forbidden, errors.NotFound): + e = Embed( + description=":x: **This message contained an attachment, but it could not be retrieved**", + color=Color.red() + ) + await self.webhook_send( + embed=e, + username=msg.author.display_name, + avatar_url=msg.author.avatar_url + ) + except discord.HTTPException as exc: + self.log.exception( + "Failed to send an attachment to the webhook", + exc_info=exc + ) + + self.message_history.message_count += 1 + + async def send_header(self, msg: Message) -> None: + """Sends a header embed with information about the relayed messages to the watch channel.""" + user_id = msg.author.id + + guild = self.bot.get_guild(GuildConfig.id) + actor = guild.get_member(self.watched_users[user_id]['actor']) + actor = actor.display_name if actor else self.watched_users[user_id]['actor'] + + inserted_at = self.watched_users[user_id]['inserted_at'] + time_delta = self._get_time_delta(inserted_at) + + reason = self.watched_users[user_id]['reason'] + + if isinstance(msg.channel, DMChannel): + # If a watched user DMs the bot there won't be a channel name or jump URL + # This could technically include a GroupChannel but bot's can't be in those + message_jump = "via DM" + else: + message_jump = f"in [#{msg.channel.name}]({msg.jump_url})" + + footer = f"Added {time_delta} by {actor} | Reason: {reason}" + embed = Embed(description=f"{msg.author.mention} {message_jump}") + embed.set_footer(text=textwrap.shorten(footer, width=128, placeholder="...")) + + await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.avatar_url) + + async def list_watched_users( + self, ctx: Context, oldest_first: bool = False, update_cache: bool = True + ) -> None: + """ + Gives an overview of the watched user list for this channel. + + The optional kwarg `oldest_first` orders the list by oldest entry. + + The optional kwarg `update_cache` specifies whether the cache should + be refreshed by polling the API. + """ + if update_cache: + if not await self.fetch_user_cache(): + await ctx.send(f":x: Failed to update {self.__class__.__name__} user cache, serving from cache") + update_cache = False + + lines = [] + for user_id, user_data in self.watched_users.items(): + inserted_at = user_data['inserted_at'] + time_delta = self._get_time_delta(inserted_at) + lines.append(f"• <@{user_id}> (added {time_delta})") + + if oldest_first: + lines.reverse() + + lines = lines or ("There's nothing here yet.",) + + embed = Embed( + title=f"{self.__class__.__name__} watched users ({'updated' if update_cache else 'cached'})", + color=Color.blue() + ) + await LinePaginator.paginate(lines, ctx, embed, empty=False) + + @staticmethod + def _get_time_delta(time_string: str) -> str: + """Returns the time in human-readable time delta format.""" + date_time = dateutil.parser.isoparse(time_string).replace(tzinfo=None) + time_delta = time_since(date_time, precision="minutes", max_units=1) + + return time_delta + + def _remove_user(self, user_id: int) -> None: + """Removes a user from a watch channel.""" + self.watched_users.pop(user_id, None) + self.message_queue.pop(user_id, None) + self.consumption_queue.pop(user_id, None) + + def cog_unload(self) -> None: + """Takes care of unloading the cog and canceling the consumption task.""" + self.log.trace("Unloading the cog") + if self._consume_task and not self._consume_task.done(): + self._consume_task.cancel() + try: + self._consume_task.result() + except asyncio.CancelledError as e: + self.log.exception( + "The consume task was canceled. Messages may be lost.", + exc_info=e + ) diff --git a/bot/cogs/python_news.py b/bot/cogs/python_news.py deleted file mode 100644 index 0ab5738a4..000000000 --- a/bot/cogs/python_news.py +++ /dev/null @@ -1,232 +0,0 @@ -import logging -import typing as t -from datetime import date, datetime - -import discord -import feedparser -from bs4 import BeautifulSoup -from discord.ext.commands import Cog -from discord.ext.tasks import loop - -from bot import constants -from bot.bot import Bot -from bot.utils.webhooks import send_webhook - -PEPS_RSS_URL = "https://www.python.org/dev/peps/peps.rss/" - -RECENT_THREADS_TEMPLATE = "https://mail.python.org/archives/list/{name}@python.org/recent-threads" -THREAD_TEMPLATE_URL = "https://mail.python.org/archives/api/list/{name}@python.org/thread/{id}/" -MAILMAN_PROFILE_URL = "https://mail.python.org/archives/users/{id}/" -THREAD_URL = "https://mail.python.org/archives/list/{list}@python.org/thread/{id}/" - -AVATAR_URL = "https://www.python.org/static/opengraph-icon-200x200.png" - -log = logging.getLogger(__name__) - - -class PythonNews(Cog): - """Post new PEPs and Python News to `#python-news`.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.webhook_names = {} - self.webhook: t.Optional[discord.Webhook] = None - - self.bot.loop.create_task(self.get_webhook_names()) - self.bot.loop.create_task(self.get_webhook_and_channel()) - - async def start_tasks(self) -> None: - """Start the tasks for fetching new PEPs and mailing list messages.""" - self.fetch_new_media.start() - - @loop(minutes=20) - async def fetch_new_media(self) -> None: - """Fetch new mailing list messages and then new PEPs.""" - await self.post_maillist_news() - await self.post_pep_news() - - async def sync_maillists(self) -> None: - """Sync currently in-use maillists with API.""" - # Wait until guild is available to avoid running before everything is ready - await self.bot.wait_until_guild_available() - - response = await self.bot.api_client.get("bot/bot-settings/news") - for mail in constants.PythonNews.mail_lists: - if mail not in response["data"]: - response["data"][mail] = [] - - # Because we are handling PEPs differently, we don't include it to mail lists - if "pep" not in response["data"]: - response["data"]["pep"] = [] - - await self.bot.api_client.put("bot/bot-settings/news", json=response) - - async def get_webhook_names(self) -> None: - """Get webhook author names from maillist API.""" - await self.bot.wait_until_guild_available() - - async with self.bot.http_session.get("https://mail.python.org/archives/api/lists") as resp: - lists = await resp.json() - - for mail in lists: - if mail["name"].split("@")[0] in constants.PythonNews.mail_lists: - self.webhook_names[mail["name"].split("@")[0]] = mail["display_name"] - - async def post_pep_news(self) -> None: - """Fetch new PEPs and when they don't have announcement in #python-news, create it.""" - # Wait until everything is ready and http_session available - await self.bot.wait_until_guild_available() - await self.sync_maillists() - - async with self.bot.http_session.get(PEPS_RSS_URL) as resp: - data = feedparser.parse(await resp.text("utf-8")) - - news_listing = await self.bot.api_client.get("bot/bot-settings/news") - payload = news_listing.copy() - pep_numbers = news_listing["data"]["pep"] - - # Reverse entries to send oldest first - data["entries"].reverse() - for new in data["entries"]: - try: - new_datetime = datetime.strptime(new["published"], "%a, %d %b %Y %X %Z") - except ValueError: - log.warning(f"Wrong datetime format passed in PEP new: {new['published']}") - continue - pep_nr = new["title"].split(":")[0].split()[1] - if ( - pep_nr in pep_numbers - or new_datetime.date() < date.today() - ): - continue - - # Build an embed and send a webhook - embed = discord.Embed( - title=new["title"], - description=new["summary"], - timestamp=new_datetime, - url=new["link"], - colour=constants.Colours.soft_green - ) - embed.set_footer(text=data["feed"]["title"], icon_url=AVATAR_URL) - msg = await send_webhook( - webhook=self.webhook, - username=data["feed"]["title"], - embed=embed, - avatar_url=AVATAR_URL, - wait=True, - ) - payload["data"]["pep"].append(pep_nr) - - # Increase overall PEP new stat - self.bot.stats.incr("python_news.posted.pep") - - if msg.channel.is_news(): - log.trace("Publishing PEP annnouncement because it was in a news channel") - await msg.publish() - - # Apply new sent news to DB to avoid duplicate sending - await self.bot.api_client.put("bot/bot-settings/news", json=payload) - - async def post_maillist_news(self) -> None: - """Send new maillist threads to #python-news that is listed in configuration.""" - await self.bot.wait_until_guild_available() - await self.sync_maillists() - existing_news = await self.bot.api_client.get("bot/bot-settings/news") - payload = existing_news.copy() - - for maillist in constants.PythonNews.mail_lists: - async with self.bot.http_session.get(RECENT_THREADS_TEMPLATE.format(name=maillist)) as resp: - recents = BeautifulSoup(await resp.text(), features="lxml") - - # When a

element is present in the response then the mailing list - # has not had any activity during the current month, so therefore it - # can be ignored. - if recents.p: - continue - - for thread in recents.html.body.div.find_all("a", href=True): - # We want only these threads that have identifiers - if "latest" in thread["href"]: - continue - - thread_information, email_information = await self.get_thread_and_first_mail( - maillist, thread["href"].split("/")[-2] - ) - - try: - new_date = datetime.strptime(email_information["date"], "%Y-%m-%dT%X%z") - except ValueError: - log.warning(f"Invalid datetime from Thread email: {email_information['date']}") - continue - - if ( - thread_information["thread_id"] in existing_news["data"][maillist] - or 'Re: ' in thread_information["subject"] - or new_date.date() < date.today() - ): - continue - - content = email_information["content"] - link = THREAD_URL.format(id=thread["href"].split("/")[-2], list=maillist) - - # Build an embed and send a message to the webhook - embed = discord.Embed( - title=thread_information["subject"], - description=content[:500] + f"... [continue reading]({link})" if len(content) > 500 else content, - timestamp=new_date, - url=link, - colour=constants.Colours.soft_green - ) - embed.set_author( - name=f"{email_information['sender_name']} ({email_information['sender']['address']})", - url=MAILMAN_PROFILE_URL.format(id=email_information["sender"]["mailman_id"]), - ) - embed.set_footer( - text=f"Posted to {self.webhook_names[maillist]}", - icon_url=AVATAR_URL, - ) - msg = await send_webhook( - webhook=self.webhook, - username=self.webhook_names[maillist], - embed=embed, - avatar_url=AVATAR_URL, - wait=True, - ) - payload["data"][maillist].append(thread_information["thread_id"]) - - # Increase this specific maillist counter in stats - self.bot.stats.incr(f"python_news.posted.{maillist.replace('-', '_')}") - - if msg.channel.is_news(): - log.trace("Publishing mailing list message because it was in a news channel") - await msg.publish() - - await self.bot.api_client.put("bot/bot-settings/news", json=payload) - - async def get_thread_and_first_mail(self, maillist: str, thread_identifier: str) -> t.Tuple[t.Any, t.Any]: - """Get mail thread and first mail from mail.python.org based on `maillist` and `thread_identifier`.""" - async with self.bot.http_session.get( - THREAD_TEMPLATE_URL.format(name=maillist, id=thread_identifier) - ) as resp: - thread_information = await resp.json() - - async with self.bot.http_session.get(thread_information["starting_email"]) as resp: - email_information = await resp.json() - return thread_information, email_information - - async def get_webhook_and_channel(self) -> None: - """Storage #python-news channel Webhook and `TextChannel` to `News.webhook` and `channel`.""" - await self.bot.wait_until_guild_available() - self.webhook = await self.bot.fetch_webhook(constants.PythonNews.webhook) - - await self.start_tasks() - - def cog_unload(self) -> None: - """Stop news posting tasks on cog unload.""" - self.fetch_new_media.cancel() - - -def setup(bot: Bot) -> None: - """Add `News` cog.""" - bot.add_cog(PythonNews(bot)) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py deleted file mode 100644 index d853ab2ea..000000000 --- a/bot/cogs/reddit.py +++ /dev/null @@ -1,304 +0,0 @@ -import asyncio -import logging -import random -import textwrap -from collections import namedtuple -from datetime import datetime, timedelta -from typing import List - -from aiohttp import BasicAuth, ClientError -from discord import Colour, Embed, TextChannel -from discord.ext.commands import Cog, Context, group -from discord.ext.tasks import loop - -from bot.bot import Bot -from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES, Webhooks -from bot.converters import Subreddit -from bot.decorators import with_role -from bot.pagination import LinePaginator -from bot.utils.messages import sub_clyde - -log = logging.getLogger(__name__) - -AccessToken = namedtuple("AccessToken", ["token", "expires_at"]) - - -class Reddit(Cog): - """Track subreddit posts and show detailed statistics about them.""" - - HEADERS = {"User-Agent": "python3:python-discord/bot:1.0.0 (by /u/PythonDiscord)"} - URL = "https://www.reddit.com" - OAUTH_URL = "https://oauth.reddit.com" - MAX_RETRIES = 3 - - def __init__(self, bot: Bot): - self.bot = bot - - self.webhook = None - self.access_token = None - self.client_auth = BasicAuth(RedditConfig.client_id, RedditConfig.secret) - - bot.loop.create_task(self.init_reddit_ready()) - self.auto_poster_loop.start() - - def cog_unload(self) -> None: - """Stop the loop task and revoke the access token when the cog is unloaded.""" - self.auto_poster_loop.cancel() - if self.access_token and self.access_token.expires_at > datetime.utcnow(): - asyncio.create_task(self.revoke_access_token()) - - async def init_reddit_ready(self) -> None: - """Sets the reddit webhook when the cog is loaded.""" - await self.bot.wait_until_guild_available() - if not self.webhook: - self.webhook = await self.bot.fetch_webhook(Webhooks.reddit) - - @property - def channel(self) -> TextChannel: - """Get the #reddit channel object from the bot's cache.""" - return self.bot.get_channel(Channels.reddit) - - async def get_access_token(self) -> None: - """ - Get a Reddit API OAuth2 access token and assign it to self.access_token. - - A token is valid for 1 hour. There will be MAX_RETRIES to get a token, after which the cog - will be unloaded and a ClientError raised if retrieval was still unsuccessful. - """ - for i in range(1, self.MAX_RETRIES + 1): - response = await self.bot.http_session.post( - url=f"{self.URL}/api/v1/access_token", - headers=self.HEADERS, - auth=self.client_auth, - data={ - "grant_type": "client_credentials", - "duration": "temporary" - } - ) - - if response.status == 200 and response.content_type == "application/json": - content = await response.json() - expiration = int(content["expires_in"]) - 60 # Subtract 1 minute for leeway. - self.access_token = AccessToken( - token=content["access_token"], - expires_at=datetime.utcnow() + timedelta(seconds=expiration) - ) - - log.debug(f"New token acquired; expires on UTC {self.access_token.expires_at}") - return - else: - log.debug( - f"Failed to get an access token: " - f"status {response.status} & content type {response.content_type}; " - f"retrying ({i}/{self.MAX_RETRIES})" - ) - - await asyncio.sleep(3) - - self.bot.remove_cog(self.qualified_name) - raise ClientError("Authentication with the Reddit API failed. Unloading the cog.") - - async def revoke_access_token(self) -> None: - """ - Revoke the OAuth2 access token for the Reddit API. - - For security reasons, it's good practice to revoke the token when it's no longer being used. - """ - response = await self.bot.http_session.post( - url=f"{self.URL}/api/v1/revoke_token", - headers=self.HEADERS, - auth=self.client_auth, - data={ - "token": self.access_token.token, - "token_type_hint": "access_token" - } - ) - - if response.status == 204 and response.content_type == "application/json": - self.access_token = None - else: - log.warning(f"Unable to revoke access token: status {response.status}.") - - async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> List[dict]: - """A helper method to fetch a certain amount of Reddit posts at a given route.""" - # Reddit's JSON responses only provide 25 posts at most. - if not 25 >= amount > 0: - raise ValueError("Invalid amount of subreddit posts requested.") - - # Renew the token if necessary. - if not self.access_token or self.access_token.expires_at < datetime.utcnow(): - await self.get_access_token() - - url = f"{self.OAUTH_URL}/{route}" - for _ in range(self.MAX_RETRIES): - response = await self.bot.http_session.get( - url=url, - headers={**self.HEADERS, "Authorization": f"bearer {self.access_token.token}"}, - params=params - ) - if response.status == 200 and response.content_type == 'application/json': - # Got appropriate response - process and return. - content = await response.json() - posts = content["data"]["children"] - return posts[:amount] - - await asyncio.sleep(3) - - log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}") - return list() # Failed to get appropriate response within allowed number of retries. - - async def get_top_posts(self, subreddit: Subreddit, time: str = "all", amount: int = 5) -> Embed: - """ - Get the top amount of posts for a given subreddit within a specified timeframe. - - A time of "all" will get posts from all time, "day" will get top daily posts and "week" will get the top - weekly posts. - - The amount should be between 0 and 25 as Reddit's JSON requests only provide 25 posts at most. - """ - embed = Embed(description="") - - posts = await self.fetch_posts( - route=f"{subreddit}/top", - amount=amount, - params={"t": time} - ) - - if not posts: - embed.title = random.choice(ERROR_REPLIES) - embed.colour = Colour.red() - embed.description = ( - "Sorry! We couldn't find any posts from that subreddit. " - "If this problem persists, please let us know." - ) - - return embed - - for post in posts: - data = post["data"] - - text = data["selftext"] - if text: - text = textwrap.shorten(text, width=128, placeholder="...") - text += "\n" # Add newline to separate embed info - - ups = data["ups"] - comments = data["num_comments"] - author = data["author"] - - title = textwrap.shorten(data["title"], width=64, placeholder="...") - link = self.URL + data["permalink"] - - embed.description += ( - f"**[{title}]({link})**\n" - f"{text}" - f"{Emojis.upvotes} {ups} {Emojis.comments} {comments} {Emojis.user} {author}\n\n" - ) - - embed.colour = Colour.blurple() - return embed - - @loop() - async def auto_poster_loop(self) -> None: - """Post the top 5 posts daily, and the top 5 posts weekly.""" - # once we upgrade to d.py 1.3 this can be removed and the loop can use the `time=datetime.time.min` parameter - now = datetime.utcnow() - tomorrow = now + timedelta(days=1) - midnight_tomorrow = tomorrow.replace(hour=0, minute=0, second=0) - seconds_until = (midnight_tomorrow - now).total_seconds() - - await asyncio.sleep(seconds_until) - - await self.bot.wait_until_guild_available() - if not self.webhook: - await self.bot.fetch_webhook(Webhooks.reddit) - - if datetime.utcnow().weekday() == 0: - await self.top_weekly_posts() - # if it's a monday send the top weekly posts - - for subreddit in RedditConfig.subreddits: - top_posts = await self.get_top_posts(subreddit=subreddit, time="day") - username = sub_clyde(f"{subreddit} Top Daily Posts") - message = await self.webhook.send(username=username, embed=top_posts, wait=True) - - if message.channel.is_news(): - await message.publish() - - async def top_weekly_posts(self) -> None: - """Post a summary of the top posts.""" - for subreddit in RedditConfig.subreddits: - # Send and pin the new weekly posts. - top_posts = await self.get_top_posts(subreddit=subreddit, time="week") - username = sub_clyde(f"{subreddit} Top Weekly Posts") - message = await self.webhook.send(wait=True, username=username, embed=top_posts) - - if subreddit.lower() == "r/python": - if not self.channel: - log.warning("Failed to get #reddit channel to remove pins in the weekly loop.") - return - - # Remove the oldest pins so that only 12 remain at most. - pins = await self.channel.pins() - - while len(pins) >= 12: - await pins[-1].unpin() - del pins[-1] - - await message.pin() - - if message.channel.is_news(): - await message.publish() - - @group(name="reddit", invoke_without_command=True) - async def reddit_group(self, ctx: Context) -> None: - """View the top posts from various subreddits.""" - await ctx.send_help(ctx.command) - - @reddit_group.command(name="top") - async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: - """Send the top posts of all time from a given subreddit.""" - async with ctx.typing(): - embed = await self.get_top_posts(subreddit=subreddit, time="all") - - await ctx.send(content=f"Here are the top {subreddit} posts of all time!", embed=embed) - - @reddit_group.command(name="daily") - async def daily_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: - """Send the top posts of today from a given subreddit.""" - async with ctx.typing(): - embed = await self.get_top_posts(subreddit=subreddit, time="day") - - await ctx.send(content=f"Here are today's top {subreddit} posts!", embed=embed) - - @reddit_group.command(name="weekly") - async def weekly_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: - """Send the top posts of this week from a given subreddit.""" - async with ctx.typing(): - embed = await self.get_top_posts(subreddit=subreddit, time="week") - - await ctx.send(content=f"Here are this week's top {subreddit} posts!", embed=embed) - - @with_role(*STAFF_ROLES) - @reddit_group.command(name="subreddits", aliases=("subs",)) - async def subreddits_command(self, ctx: Context) -> None: - """Send a paginated embed of all the subreddits we're relaying.""" - embed = Embed() - embed.title = "Relayed subreddits." - embed.colour = Colour.blurple() - - await LinePaginator.paginate( - RedditConfig.subreddits, - ctx, embed, - footer_text="Use the reddit commands along with these to view their posts.", - empty=False, - max_lines=15 - ) - - -def setup(bot: Bot) -> None: - """Load the Reddit cog.""" - if not RedditConfig.secret or not RedditConfig.client_id: - log.error("Credentials not provided, cog not loaded.") - return - bot.add_cog(Reddit(bot)) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py deleted file mode 100644 index 670493bcf..000000000 --- a/bot/cogs/reminders.py +++ /dev/null @@ -1,427 +0,0 @@ -import asyncio -import logging -import random -import textwrap -import typing as t -from datetime import datetime, timedelta -from operator import itemgetter - -import discord -from dateutil.parser import isoparse -from dateutil.relativedelta import relativedelta -from discord.ext.commands import Cog, Context, Greedy, group - -from bot.bot import Bot -from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, STAFF_ROLES -from bot.converters import Duration -from bot.pagination import LinePaginator -from bot.utils.checks import without_role_check -from bot.utils.messages import send_denial -from bot.utils.scheduling import Scheduler -from bot.utils.time import humanize_delta - -log = logging.getLogger(__name__) - -WHITELISTED_CHANNELS = Guild.reminder_whitelist -MAXIMUM_REMINDERS = 5 - -Mentionable = t.Union[discord.Member, discord.Role] - - -class Reminders(Cog): - """Provide in-channel reminder functionality.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.scheduler = Scheduler(self.__class__.__name__) - - self.bot.loop.create_task(self.reschedule_reminders()) - - def cog_unload(self) -> None: - """Cancel scheduled tasks.""" - self.scheduler.cancel_all() - - async def reschedule_reminders(self) -> None: - """Get all current reminders from the API and reschedule them.""" - await self.bot.wait_until_guild_available() - response = await self.bot.api_client.get( - 'bot/reminders', - params={'active': 'true'} - ) - - now = datetime.utcnow() - - for reminder in response: - is_valid, *_ = self.ensure_valid_reminder(reminder, cancel_task=False) - if not is_valid: - continue - - remind_at = isoparse(reminder['expiration']).replace(tzinfo=None) - - # If the reminder is already overdue ... - if remind_at < now: - late = relativedelta(now, remind_at) - await self.send_reminder(reminder, late) - else: - self.schedule_reminder(reminder) - - def ensure_valid_reminder( - self, - reminder: dict, - cancel_task: bool = True - ) -> t.Tuple[bool, discord.User, discord.TextChannel]: - """Ensure reminder author and channel can be fetched otherwise delete the reminder.""" - user = self.bot.get_user(reminder['author']) - channel = self.bot.get_channel(reminder['channel_id']) - is_valid = True - if not user or not channel: - is_valid = False - log.info( - f"Reminder {reminder['id']} invalid: " - f"User {reminder['author']}={user}, Channel {reminder['channel_id']}={channel}." - ) - asyncio.create_task(self._delete_reminder(reminder['id'], cancel_task)) - - return is_valid, user, channel - - @staticmethod - async def _send_confirmation( - ctx: Context, - on_success: str, - reminder_id: str, - 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 - - 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')}" - - embed.set_footer(text=footer_str) - - await ctx.send(embed=embed) - - @staticmethod - async def _check_mentions(ctx: Context, mentions: t.Iterable[Mentionable]) -> t.Tuple[bool, str]: - """ - Returns whether or not the list of mentions is allowed. - - Conditions: - - Role reminders are Mods+ - - Reminders for other users are Helpers+ - - If mentions aren't allowed, also return the type of mention(s) disallowed. - """ - if without_role_check(ctx, *STAFF_ROLES): - return False, "members/roles" - elif without_role_check(ctx, *MODERATION_ROLES): - return all(isinstance(mention, discord.Member) for mention in mentions), "roles" - else: - return True, "" - - @staticmethod - async def validate_mentions(ctx: Context, mentions: t.Iterable[Mentionable]) -> bool: - """ - Filter mentions to see if the user can mention, and sends a denial if not allowed. - - Returns whether or not the validation is successful. - """ - mentions_allowed, disallowed_mentions = await Reminders._check_mentions(ctx, mentions) - - if not mentions or mentions_allowed: - return True - else: - await send_denial(ctx, f"You can't mention other {disallowed_mentions} in your reminder!") - return False - - def get_mentionables(self, mention_ids: t.List[int]) -> t.Iterator[Mentionable]: - """Converts Role and Member ids to their corresponding objects if possible.""" - guild = self.bot.get_guild(Guild.id) - for mention_id in mention_ids: - if (mentionable := (guild.get_member(mention_id) or guild.get_role(mention_id))): - yield mentionable - - def schedule_reminder(self, reminder: dict) -> None: - """A coroutine which sends the reminder once the time is reached, and cancels the running task.""" - reminder_id = reminder["id"] - reminder_datetime = isoparse(reminder['expiration']).replace(tzinfo=None) - - async def _remind() -> None: - await self.send_reminder(reminder) - - log.debug(f"Deleting reminder {reminder_id} (the user has been reminded).") - await self._delete_reminder(reminder_id) - - self.scheduler.schedule_at(reminder_datetime, reminder_id, _remind()) - - async def _delete_reminder(self, reminder_id: str, cancel_task: bool = True) -> None: - """Delete a reminder from the database, given its ID, and cancel the running task.""" - await self.bot.api_client.delete('bot/reminders/' + str(reminder_id)) - - if cancel_task: - # Now we can remove it from the schedule list - self.scheduler.cancel(reminder_id) - - async def _edit_reminder(self, reminder_id: int, payload: dict) -> dict: - """ - Edits a reminder in the database given the ID and payload. - - Returns the edited reminder. - """ - # Send the request to update the reminder in the database - reminder = await self.bot.api_client.patch( - 'bot/reminders/' + str(reminder_id), - json=payload - ) - return reminder - - async def _reschedule_reminder(self, reminder: dict) -> None: - """Reschedule a reminder object.""" - log.trace(f"Cancelling old task #{reminder['id']}") - self.scheduler.cancel(reminder["id"]) - - log.trace(f"Scheduling new task #{reminder['id']}") - self.schedule_reminder(reminder) - - async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: - """Send the reminder.""" - is_valid, user, channel = self.ensure_valid_reminder(reminder) - if not is_valid: - return - - embed = discord.Embed() - embed.colour = discord.Colour.blurple() - embed.set_author( - icon_url=Icons.remind_blurple, - name="It has arrived!" - ) - - embed.description = f"Here's your reminder: `{reminder['content']}`." - - if reminder.get("jump_url"): # keep backward compatibility - embed.description += f"\n[Jump back to when you created the reminder]({reminder['jump_url']})" - - if late: - embed.colour = discord.Colour.red() - embed.set_author( - icon_url=Icons.remind_red, - name=f"Sorry it arrived {humanize_delta(late, max_units=2)} late!" - ) - - additional_mentions = ' '.join( - mentionable.mention for mentionable in self.get_mentionables(reminder["mentions"]) - ) - - await channel.send( - content=f"{user.mention} {additional_mentions}", - embed=embed - ) - await self._delete_reminder(reminder["id"]) - - @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True) - async def remind_group( - self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str - ) -> None: - """Commands for managing your reminders.""" - await ctx.invoke(self.new_reminder, mentions=mentions, expiration=expiration, content=content) - - @remind_group.command(name="new", aliases=("add", "create")) - async def new_reminder( - self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str - ) -> None: - """ - Set yourself a simple reminder. - - Expiration is parsed per: http://strftime.org/ - """ - # If the user is not staff, we need to verify whether or not to make a reminder at all. - if without_role_check(ctx, *STAFF_ROLES): - - # If they don't have permission to set a reminder in this channel - if ctx.channel.id not in WHITELISTED_CHANNELS: - await send_denial(ctx, "Sorry, you can't do that here!") - return - - # Get their current active reminders - active_reminders = await self.bot.api_client.get( - 'bot/reminders', - params={ - 'author__id': str(ctx.author.id) - } - ) - - # Let's limit this, so we don't get 10 000 - # reminders from kip or something like that :P - if len(active_reminders) > MAXIMUM_REMINDERS: - await send_denial(ctx, "You have too many active reminders!") - return - - # Remove duplicate mentions - mentions = set(mentions) - mentions.discard(ctx.author) - - # Filter mentions to see if the user can mention members/roles - if not await self.validate_mentions(ctx, mentions): - return - - mention_ids = [mention.id for mention in mentions] - - # Now we can attempt to actually set the reminder. - reminder = await self.bot.api_client.post( - 'bot/reminders', - json={ - 'author': ctx.author.id, - 'channel_id': ctx.message.channel.id, - 'jump_url': ctx.message.jump_url, - 'content': content, - 'expiration': expiration.isoformat(), - 'mentions': mention_ids, - } - ) - - now = datetime.utcnow() - timedelta(seconds=1) - humanized_delta = humanize_delta(relativedelta(expiration, now)) - mention_string = ( - f"Your reminder will arrive in {humanized_delta} " - f"and will mention {len(mentions)} other(s)!" - ) - - # Confirm to the user that it worked. - await self._send_confirmation( - ctx, - on_success=mention_string, - reminder_id=reminder["id"], - delivery_dt=expiration, - ) - - self.schedule_reminder(reminder) - - @remind_group.command(name="list") - async def list_reminders(self, ctx: Context) -> None: - """View a paginated embed of all reminders for your user.""" - # Get all the user's reminders from the database. - data = await self.bot.api_client.get( - 'bot/reminders', - params={'author__id': str(ctx.author.id)} - ) - - now = datetime.utcnow() - - # Make a list of tuples so it can be sorted by time. - reminders = sorted( - ( - (rem['content'], rem['expiration'], rem['id'], rem['mentions']) - for rem in data - ), - key=itemgetter(1) - ) - - lines = [] - - for content, remind_at, id_, mentions in reminders: - # Parse and humanize the time, make it pretty :D - remind_datetime = isoparse(remind_at).replace(tzinfo=None) - time = humanize_delta(relativedelta(remind_datetime, now)) - - mentions = ", ".join( - # Both Role and User objects have the `name` attribute - mention.name for mention in self.get_mentionables(mentions) - ) - mention_string = f"\n**Mentions:** {mentions}" if mentions else "" - - text = textwrap.dedent(f""" - **Reminder #{id_}:** *expires in {time}* (ID: {id_}){mention_string} - {content} - """).strip() - - lines.append(text) - - embed = discord.Embed() - embed.colour = discord.Colour.blurple() - embed.title = f"Reminders for {ctx.author}" - - # Remind the user that they have no reminders :^) - if not lines: - embed.description = "No active reminders could be found." - await ctx.send(embed=embed) - return - - # Construct the embed and paginate it. - embed.colour = discord.Colour.blurple() - - await LinePaginator.paginate( - lines, - ctx, embed, - max_lines=3, - empty=True - ) - - @remind_group.group(name="edit", aliases=("change", "modify"), invoke_without_command=True) - async def edit_reminder_group(self, ctx: Context) -> None: - """Commands for modifying your current reminders.""" - await ctx.send_help(ctx.command) - - @edit_reminder_group.command(name="duration", aliases=("time",)) - async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: Duration) -> None: - """ - Edit one of your reminder's expiration. - - Expiration is parsed per: http://strftime.org/ - """ - await self.edit_reminder(ctx, id_, {'expiration': expiration.isoformat()}) - - @edit_reminder_group.command(name="content", aliases=("reason",)) - async def edit_reminder_content(self, ctx: Context, id_: int, *, content: str) -> None: - """Edit one of your reminder's content.""" - await self.edit_reminder(ctx, id_, {"content": content}) - - @edit_reminder_group.command(name="mentions", aliases=("pings",)) - async def edit_reminder_mentions(self, ctx: Context, id_: int, mentions: Greedy[Mentionable]) -> None: - """Edit one of your reminder's mentions.""" - # Remove duplicate mentions - mentions = set(mentions) - mentions.discard(ctx.author) - - # Filter mentions to see if the user can mention members/roles - if not await self.validate_mentions(ctx, mentions): - return - - mention_ids = [mention.id for mention in mentions] - await self.edit_reminder(ctx, id_, {"mentions": mention_ids}) - - async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: - """Edits a reminder with the given payload, then sends a confirmation message.""" - reminder = await self._edit_reminder(id_, payload) - - # Parse the reminder expiration back into a datetime - expiration = isoparse(reminder["expiration"]).replace(tzinfo=None) - - # Send a confirmation message to the channel - await self._send_confirmation( - ctx, - on_success="That reminder has been edited successfully!", - reminder_id=id_, - delivery_dt=expiration, - ) - await self._reschedule_reminder(reminder) - - @remind_group.command("delete", aliases=("remove", "cancel")) - async def delete_reminder(self, ctx: Context, id_: int) -> None: - """Delete one of your active reminders.""" - await self._delete_reminder(id_) - await self._send_confirmation( - ctx, - on_success="That reminder has been deleted successfully!", - reminder_id=id_, - delivery_dt=None, - ) - - -def setup(bot: Bot) -> None: - """Load the Reminders cog.""" - bot.add_cog(Reminders(bot)) diff --git a/bot/cogs/security.py b/bot/cogs/security.py deleted file mode 100644 index c680c5e27..000000000 --- a/bot/cogs/security.py +++ /dev/null @@ -1,31 +0,0 @@ -import logging - -from discord.ext.commands import Cog, Context, NoPrivateMessage - -from bot.bot import Bot - -log = logging.getLogger(__name__) - - -class Security(Cog): - """Security-related helpers.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.bot.check(self.check_not_bot) # Global commands check - no bots can run any commands at all - self.bot.check(self.check_on_guild) # Global commands check - commands can't be run in a DM - - def check_not_bot(self, ctx: Context) -> bool: - """Check if the context is a bot user.""" - return not ctx.author.bot - - def check_on_guild(self, ctx: Context) -> bool: - """Check if the context is in a guild.""" - if ctx.guild is None: - raise NoPrivateMessage("This command cannot be used in private messages.") - return True - - -def setup(bot: Bot) -> None: - """Load the Security cog.""" - bot.add_cog(Security(bot)) diff --git a/bot/cogs/site.py b/bot/cogs/site.py deleted file mode 100644 index ac29daa1d..000000000 --- a/bot/cogs/site.py +++ /dev/null @@ -1,146 +0,0 @@ -import logging - -from discord import Colour, Embed -from discord.ext.commands import Cog, Context, group - -from bot.bot import Bot -from bot.constants import URLs -from bot.pagination import LinePaginator - -log = logging.getLogger(__name__) - -PAGES_URL = f"{URLs.site_schema}{URLs.site}/pages" - - -class Site(Cog): - """Commands for linking to different parts of the site.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @group(name="site", aliases=("s",), invoke_without_command=True) - async def site_group(self, ctx: Context) -> None: - """Commands for getting info about our website.""" - await ctx.send_help(ctx.command) - - @site_group.command(name="home", aliases=("about",)) - async def site_main(self, ctx: Context) -> None: - """Info about the website itself.""" - url = f"{URLs.site_schema}{URLs.site}/" - - embed = Embed(title="Python Discord website") - embed.set_footer(text=url) - embed.colour = Colour.blurple() - embed.description = ( - f"[Our official website]({url}) is an open-source community project " - "created with Python and Django. It contains information about the server " - "itself, lets you sign up for upcoming events, has its own wiki, contains " - "a list of valuable learning resources, and much more." - ) - - await ctx.send(embed=embed) - - @site_group.command(name="resources") - async def site_resources(self, ctx: Context) -> None: - """Info about the site's Resources page.""" - learning_url = f"{PAGES_URL}/resources" - - embed = Embed(title="Resources") - embed.set_footer(text=f"{learning_url}") - embed.colour = Colour.blurple() - embed.description = ( - f"The [Resources page]({learning_url}) on our website contains a " - "list of hand-selected learning resources that we regularly recommend " - f"to both beginners and experts." - ) - - await ctx.send(embed=embed) - - @site_group.command(name="tools") - async def site_tools(self, ctx: Context) -> None: - """Info about the site's Tools page.""" - tools_url = f"{PAGES_URL}/resources/tools" - - embed = Embed(title="Tools") - embed.set_footer(text=f"{tools_url}") - embed.colour = Colour.blurple() - embed.description = ( - f"The [Tools page]({tools_url}) on our website contains a " - f"couple of the most popular tools for programming in Python." - ) - - await ctx.send(embed=embed) - - @site_group.command(name="help") - async def site_help(self, ctx: Context) -> None: - """Info about the site's Getting Help page.""" - url = f"{PAGES_URL}/resources/guides/asking-good-questions" - - embed = Embed(title="Asking Good Questions") - embed.set_footer(text=url) - embed.colour = Colour.blurple() - embed.description = ( - "Asking the right question about something that's new to you can sometimes be tricky. " - f"To help with this, we've created a [guide to asking good questions]({url}) on our website. " - "It contains everything you need to get the very best help from our community." - ) - - await ctx.send(embed=embed) - - @site_group.command(name="faq") - async def site_faq(self, ctx: Context) -> None: - """Info about the site's FAQ page.""" - url = f"{PAGES_URL}/frequently-asked-questions" - - embed = Embed(title="FAQ") - embed.set_footer(text=url) - embed.colour = Colour.blurple() - embed.description = ( - "As the largest Python community on Discord, we get hundreds of questions every day. " - "Many of these questions have been asked before. We've compiled a list of the most " - "frequently asked questions along with their answers, which can be found on " - f"our [FAQ page]({url})." - ) - - await ctx.send(embed=embed) - - @site_group.command(aliases=['r', 'rule'], name='rules') - async def site_rules(self, ctx: Context, *rules: int) -> None: - """Provides a link to all rules or, if specified, displays specific rule(s).""" - rules_embed = Embed(title='Rules', color=Colour.blurple()) - rules_embed.url = f"{PAGES_URL}/rules" - - if not rules: - # Rules were not submitted. Return the default description. - rules_embed.description = ( - "The rules and guidelines that apply to this community can be found on" - f" our [rules page]({PAGES_URL}/rules). We expect" - " all members of the community to have read and understood these." - ) - - await ctx.send(embed=rules_embed) - return - - full_rules = await self.bot.api_client.get('rules', params={'link_format': 'md'}) - invalid_indices = tuple( - pick - for pick in rules - if pick < 1 or pick > len(full_rules) - ) - - if invalid_indices: - indices = ', '.join(map(str, invalid_indices)) - await ctx.send(f":x: Invalid rule indices: {indices}") - return - - for rule in rules: - self.bot.stats.incr(f"rule_uses.{rule}") - - final_rules = tuple(f"**{pick}.** {full_rules[pick - 1]}" for pick in rules) - - await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3) - - -def setup(bot: Bot) -> None: - """Load the Site cog.""" - bot.add_cog(Site(bot)) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py deleted file mode 100644 index 52c8b6f88..000000000 --- a/bot/cogs/snekbox.py +++ /dev/null @@ -1,349 +0,0 @@ -import asyncio -import contextlib -import datetime -import logging -import re -import textwrap -from functools import partial -from signal import Signals -from typing import Optional, Tuple - -from discord import HTTPException, Message, NotFound, Reaction, User -from discord.ext.commands import Cog, Context, command, guild_only - -from bot.bot import Bot -from bot.constants import Categories, Channels, Roles, URLs -from bot.decorators import in_whitelist -from bot.utils.messages import wait_for_deletion - -log = logging.getLogger(__name__) - -ESCAPE_REGEX = re.compile("[`\u202E\u200B]{3,}") -FORMATTED_CODE_REGEX = re.compile( - r"^\s*" # any leading whitespace from the beginning of the string - r"(?P(?P```)|``?)" # code delimiter: 1-3 backticks; (?P=block) only matches if it's a block - r"(?(block)(?:(?P[a-z]+)\n)?)" # if we're in a block, match optional language (only letters plus newline) - r"(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code - r"(?P.*?)" # extract all code inside the markup - r"\s*" # any more whitespace before the end of the code markup - r"(?P=delim)" # match the exact same delimiter from the start again - r"\s*$", # any trailing whitespace until the end of the string - re.DOTALL | re.IGNORECASE # "." also matches newlines, case insensitive -) -RAW_CODE_REGEX = re.compile( - r"^(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code - r"(?P.*?)" # extract all the rest as code - r"\s*$", # any trailing whitespace until the end of the string - re.DOTALL # "." also matches newlines -) - -MAX_PASTE_LEN = 1000 - -# `!eval` command whitelists -EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric) -EVAL_CATEGORIES = (Categories.help_available, Categories.help_in_use) -EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners) - -SIGKILL = 9 - -REEVAL_EMOJI = '\U0001f501' # :repeat: -REEVAL_TIMEOUT = 30 - - -class Snekbox(Cog): - """Safe evaluation of Python code using Snekbox.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.jobs = {} - - async def post_eval(self, code: str) -> dict: - """Send a POST request to the Snekbox API to evaluate code and return the results.""" - url = URLs.snekbox_eval_api - data = {"input": code} - async with self.bot.http_session.post(url, json=data, raise_for_status=True) as resp: - return await resp.json() - - async def upload_output(self, output: str) -> Optional[str]: - """Upload the eval output to a paste service and return a URL to it if successful.""" - log.trace("Uploading full output to paste service...") - - if len(output) > MAX_PASTE_LEN: - log.info("Full output is too long to upload") - return "too long to upload" - - url = URLs.paste_service.format(key="documents") - try: - async with self.bot.http_session.post(url, data=output, raise_for_status=True) as resp: - data = await resp.json() - - if "key" in data: - return URLs.paste_service.format(key=data["key"]) - except Exception: - # 400 (Bad Request) means there are too many characters - log.exception("Failed to upload full output to paste service!") - - @staticmethod - def prepare_input(code: str) -> str: - """Extract code from the Markdown, format it, and insert it into the code template.""" - match = FORMATTED_CODE_REGEX.fullmatch(code) - if match: - code, block, lang, delim = match.group("code", "block", "lang", "delim") - code = textwrap.dedent(code) - if block: - info = (f"'{lang}' highlighted" if lang else "plain") + " code block" - else: - info = f"{delim}-enclosed inline code" - log.trace(f"Extracted {info} for evaluation:\n{code}") - else: - code = textwrap.dedent(RAW_CODE_REGEX.fullmatch(code).group("code")) - log.trace( - f"Eval message contains unformatted or badly formatted code, " - f"stripping whitespace only:\n{code}" - ) - - return code - - @staticmethod - def get_results_message(results: dict) -> Tuple[str, str]: - """Return a user-friendly message and error corresponding to the process's return code.""" - stdout, returncode = results["stdout"], results["returncode"] - msg = f"Your eval job has completed with return code {returncode}" - error = "" - - if returncode is None: - msg = "Your eval job has failed" - error = stdout.strip() - elif returncode == 128 + SIGKILL: - msg = "Your eval job timed out or ran out of memory" - elif returncode == 255: - msg = "Your eval job has failed" - error = "A fatal NsJail error occurred" - else: - # Try to append signal's name if one exists - try: - name = Signals(returncode - 128).name - msg = f"{msg} ({name})" - except ValueError: - pass - - return msg, error - - @staticmethod - def get_status_emoji(results: dict) -> str: - """Return an emoji corresponding to the status code or lack of output in result.""" - if not results["stdout"].strip(): # No output - return ":warning:" - elif results["returncode"] == 0: # No error - return ":white_check_mark:" - else: # Exception - return ":x:" - - async def format_output(self, output: str) -> Tuple[str, Optional[str]]: - """ - Format the output and return a tuple of the formatted output and a URL to the full output. - - Prepend each line with a line number. Truncate if there are over 10 lines or 1000 characters - and upload the full output to a paste service. - """ - log.trace("Formatting output...") - - output = output.rstrip("\n") - original_output = output # To be uploaded to a pasting service if needed - paste_link = None - - if "<@" in output: - output = output.replace("<@", "<@\u200B") # Zero-width space - - if " 0: - output = [f"{i:03d} | {line}" for i, line in enumerate(output.split('\n'), 1)] - output = output[:11] # Limiting to only 11 lines - output = "\n".join(output) - - if lines > 10: - truncated = True - if len(output) >= 1000: - output = f"{output[:1000]}\n... (truncated - too long, too many lines)" - else: - output = f"{output}\n... (truncated - too many lines)" - elif len(output) >= 1000: - truncated = True - output = f"{output[:1000]}\n... (truncated - too long)" - - if truncated: - paste_link = await self.upload_output(original_output) - - output = output or "[No output]" - - return output, paste_link - - async def send_eval(self, ctx: Context, code: str) -> Message: - """ - Evaluate code, format it, and send the output to the corresponding channel. - - Return the bot response. - """ - async with ctx.typing(): - results = await self.post_eval(code) - msg, error = self.get_results_message(results) - - if error: - output, paste_link = error, None - else: - output, paste_link = await self.format_output(results["stdout"]) - - icon = self.get_status_emoji(results) - msg = f"{ctx.author.mention} {icon} {msg}.\n\n```\n{output}\n```" - if paste_link: - msg = f"{msg}\nFull output: {paste_link}" - - # Collect stats of eval fails + successes - if icon == ":x:": - self.bot.stats.incr("snekbox.python.fail") - else: - self.bot.stats.incr("snekbox.python.success") - - filter_cog = self.bot.get_cog("Filtering") - filter_triggered = False - if filter_cog: - filter_triggered = await filter_cog.filter_eval(msg, ctx.message) - if filter_triggered: - response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.") - else: - response = await ctx.send(msg) - self.bot.loop.create_task( - wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot) - ) - - log.info(f"{ctx.author}'s job had a return code of {results['returncode']}") - return response - - async def continue_eval(self, ctx: Context, response: Message) -> Optional[str]: - """ - Check if the eval session should continue. - - Return the new code to evaluate or None if the eval session should be terminated. - """ - _predicate_eval_message_edit = partial(predicate_eval_message_edit, ctx) - _predicate_emoji_reaction = partial(predicate_eval_emoji_reaction, ctx) - - with contextlib.suppress(NotFound): - try: - _, new_message = await self.bot.wait_for( - 'message_edit', - check=_predicate_eval_message_edit, - timeout=REEVAL_TIMEOUT - ) - await ctx.message.add_reaction(REEVAL_EMOJI) - await self.bot.wait_for( - 'reaction_add', - check=_predicate_emoji_reaction, - timeout=10 - ) - - code = await self.get_code(new_message) - await ctx.message.clear_reactions() - with contextlib.suppress(HTTPException): - await response.delete() - - except asyncio.TimeoutError: - await ctx.message.clear_reactions() - return None - - return code - - async def get_code(self, message: Message) -> Optional[str]: - """ - Return the code from `message` to be evaluated. - - If the message is an invocation of the eval command, return the first argument or None if it - doesn't exist. Otherwise, return the full content of the message. - """ - log.trace(f"Getting context for message {message.id}.") - new_ctx = await self.bot.get_context(message) - - if new_ctx.command is self.eval_command: - log.trace(f"Message {message.id} invokes eval command.") - split = message.content.split(maxsplit=1) - code = split[1] if len(split) > 1 else None - else: - log.trace(f"Message {message.id} does not invoke eval command.") - code = message.content - - return code - - @command(name="eval", aliases=("e",)) - @guild_only() - @in_whitelist(channels=EVAL_CHANNELS, categories=EVAL_CATEGORIES, roles=EVAL_ROLES) - async def eval_command(self, ctx: Context, *, code: str = None) -> None: - """ - Run Python code and get the results. - - This command supports multiple lines of code, including code wrapped inside a formatted code - block. Code can be re-evaluated by editing the original message within 10 seconds and - clicking the reaction that subsequently appears. - - We've done our best to make this sandboxed, but do let us know if you manage to find an - issue with it! - """ - if ctx.author.id in self.jobs: - await ctx.send( - f"{ctx.author.mention} You've already got a job running - " - "please wait for it to finish!" - ) - return - - if not code: # None or empty string - await ctx.send_help(ctx.command) - return - - if Roles.helpers in (role.id for role in ctx.author.roles): - self.bot.stats.incr("snekbox_usages.roles.helpers") - else: - self.bot.stats.incr("snekbox_usages.roles.developers") - - if ctx.channel.category_id == Categories.help_in_use: - self.bot.stats.incr("snekbox_usages.channels.help") - elif ctx.channel.id == Channels.bot_commands: - self.bot.stats.incr("snekbox_usages.channels.bot_commands") - else: - self.bot.stats.incr("snekbox_usages.channels.topical") - - log.info(f"Received code from {ctx.author} for evaluation:\n{code}") - - while True: - self.jobs[ctx.author.id] = datetime.datetime.now() - code = self.prepare_input(code) - try: - response = await self.send_eval(ctx, code) - finally: - del self.jobs[ctx.author.id] - - code = await self.continue_eval(ctx, response) - if not code: - break - log.info(f"Re-evaluating code from message {ctx.message.id}:\n{code}") - - -def predicate_eval_message_edit(ctx: Context, old_msg: Message, new_msg: Message) -> bool: - """Return True if the edited message is the context message and the content was indeed modified.""" - return new_msg.id == ctx.message.id and old_msg.content != new_msg.content - - -def predicate_eval_emoji_reaction(ctx: Context, reaction: Reaction, user: User) -> bool: - """Return True if the reaction REEVAL_EMOJI was added by the context message author on this message.""" - return reaction.message.id == ctx.message.id and user.id == ctx.author.id and str(reaction) == REEVAL_EMOJI - - -def setup(bot: Bot) -> None: - """Load the Snekbox cog.""" - bot.add_cog(Snekbox(bot)) diff --git a/bot/cogs/source.py b/bot/cogs/source.py deleted file mode 100644 index 205e0ba81..000000000 --- a/bot/cogs/source.py +++ /dev/null @@ -1,141 +0,0 @@ -import inspect -from pathlib import Path -from typing import Optional, Tuple, Union - -from discord import Embed -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import URLs - -SourceType = Union[commands.HelpCommand, commands.Command, commands.Cog, str, commands.ExtensionNotLoaded] - - -class SourceConverter(commands.Converter): - """Convert an argument into a help command, tag, command, or cog.""" - - async def convert(self, ctx: commands.Context, argument: str) -> SourceType: - """Convert argument into source object.""" - if argument.lower().startswith("help"): - return ctx.bot.help_command - - cog = ctx.bot.get_cog(argument) - if cog: - return cog - - cmd = ctx.bot.get_command(argument) - if cmd: - return cmd - - tags_cog = ctx.bot.get_cog("Tags") - show_tag = True - - if not tags_cog: - show_tag = False - elif argument.lower() in tags_cog._cache: - return argument.lower() - - raise commands.BadArgument( - f"Unable to convert `{argument}` to valid command{', tag,' if show_tag else ''} or Cog." - ) - - -class BotSource(commands.Cog): - """Displays information about the bot's source code.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @commands.command(name="source", aliases=("src",)) - async def source_command(self, ctx: commands.Context, *, source_item: SourceConverter = None) -> None: - """Display information and a GitHub link to the source code of a command, tag, or cog.""" - if not source_item: - embed = Embed(title="Bot's GitHub Repository") - embed.add_field(name="Repository", value=f"[Go to GitHub]({URLs.github_bot_repo})") - embed.set_thumbnail(url="https://avatars1.githubusercontent.com/u/9919") - await ctx.send(embed=embed) - return - - embed = await self.build_embed(source_item) - await ctx.send(embed=embed) - - def get_source_link(self, source_item: SourceType) -> Tuple[str, str, Optional[int]]: - """ - Build GitHub link of source item, return this link, file location and first line number. - - Raise BadArgument if `source_item` is a dynamically-created object (e.g. via internal eval). - """ - if isinstance(source_item, commands.Command): - if source_item.cog_name == "Alias": - cmd_name = source_item.callback.__name__.replace("_alias", "") - cmd = self.bot.get_command(cmd_name.replace("_", " ")) - src = cmd.callback.__code__ - filename = src.co_filename - else: - src = source_item.callback.__code__ - filename = src.co_filename - elif isinstance(source_item, str): - tags_cog = self.bot.get_cog("Tags") - filename = tags_cog._cache[source_item]["location"] - else: - src = type(source_item) - try: - filename = inspect.getsourcefile(src) - except TypeError: - raise commands.BadArgument("Cannot get source for a dynamically-created object.") - - if not isinstance(source_item, str): - try: - lines, first_line_no = inspect.getsourcelines(src) - except OSError: - raise commands.BadArgument("Cannot get source for a dynamically-created object.") - - lines_extension = f"#L{first_line_no}-L{first_line_no+len(lines)-1}" - else: - first_line_no = None - lines_extension = "" - - # Handle tag file location differently than others to avoid errors in some cases - if not first_line_no: - file_location = Path(filename).relative_to("/bot/") - else: - file_location = Path(filename).relative_to(Path.cwd()).as_posix() - - url = f"{URLs.github_bot_repo}/blob/master/{file_location}{lines_extension}" - - return url, file_location, first_line_no or None - - async def build_embed(self, source_object: SourceType) -> Optional[Embed]: - """Build embed based on source object.""" - url, location, first_line = self.get_source_link(source_object) - - if isinstance(source_object, commands.HelpCommand): - title = "Help Command" - description = source_object.__doc__.splitlines()[1] - elif isinstance(source_object, commands.Command): - if source_object.cog_name == "Alias": - cmd_name = source_object.callback.__name__.replace("_alias", "") - cmd = self.bot.get_command(cmd_name.replace("_", " ")) - description = cmd.short_doc - else: - description = source_object.short_doc - - title = f"Command: {source_object.qualified_name}" - elif isinstance(source_object, str): - title = f"Tag: {source_object}" - description = "" - else: - title = f"Cog: {source_object.qualified_name}" - description = source_object.description.splitlines()[0] - - embed = Embed(title=title, description=description) - embed.add_field(name="Source Code", value=f"[Go to GitHub]({url})") - line_text = f":{first_line}" if first_line else "" - embed.set_footer(text=f"{location}{line_text}") - - return embed - - -def setup(bot: Bot) -> None: - """Load the BotSource cog.""" - bot.add_cog(BotSource(bot)) diff --git a/bot/cogs/stats.py b/bot/cogs/stats.py deleted file mode 100644 index d42f55466..000000000 --- a/bot/cogs/stats.py +++ /dev/null @@ -1,129 +0,0 @@ -import string -from datetime import datetime - -from discord import Member, Message, Status -from discord.ext.commands import Cog, Context -from discord.ext.tasks import loop - -from bot.bot import Bot -from bot.constants import Categories, Channels, Guild, Stats as StatConf - - -CHANNEL_NAME_OVERRIDES = { - Channels.off_topic_0: "off_topic_0", - Channels.off_topic_1: "off_topic_1", - Channels.off_topic_2: "off_topic_2", - Channels.staff_lounge: "staff_lounge" -} - -ALLOWED_CHARS = string.ascii_letters + string.digits + "_" - - -class Stats(Cog): - """A cog which provides a way to hook onto Discord events and forward to stats.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.last_presence_update = None - self.update_guild_boost.start() - - @Cog.listener() - async def on_message(self, message: Message) -> None: - """Report message events in the server to statsd.""" - if message.guild is None: - return - - if message.guild.id != Guild.id: - return - - cat = getattr(message.channel, "category", None) - if cat is not None and cat.id == Categories.modmail: - if message.channel.id != Channels.incidents: - # Do not report modmail channels to stats, there are too many - # of them for interesting statistics to be drawn out of this. - return - - reformatted_name = message.channel.name.replace('-', '_') - - if CHANNEL_NAME_OVERRIDES.get(message.channel.id): - reformatted_name = CHANNEL_NAME_OVERRIDES.get(message.channel.id) - - reformatted_name = "".join(char for char in reformatted_name if char in ALLOWED_CHARS) - - stat_name = f"channels.{reformatted_name}" - self.bot.stats.incr(stat_name) - - # Increment the total message count - self.bot.stats.incr("messages") - - @Cog.listener() - async def on_command_completion(self, ctx: Context) -> None: - """Report completed commands to statsd.""" - command_name = ctx.command.qualified_name.replace(" ", "_") - - self.bot.stats.incr(f"commands.{command_name}") - - @Cog.listener() - async def on_member_join(self, member: Member) -> None: - """Update member count stat on member join.""" - if member.guild.id != Guild.id: - return - - self.bot.stats.gauge("guild.total_members", len(member.guild.members)) - - @Cog.listener() - async def on_member_leave(self, member: Member) -> None: - """Update member count stat on member leave.""" - if member.guild.id != Guild.id: - return - - self.bot.stats.gauge("guild.total_members", len(member.guild.members)) - - @Cog.listener() - async def on_member_update(self, _before: Member, after: Member) -> None: - """Update presence estimates on member update.""" - if after.guild.id != Guild.id: - return - - if self.last_presence_update: - if (datetime.now() - self.last_presence_update).seconds < StatConf.presence_update_timeout: - return - - self.last_presence_update = datetime.now() - - online = 0 - idle = 0 - dnd = 0 - offline = 0 - - for member in after.guild.members: - if member.status is Status.online: - online += 1 - elif member.status is Status.dnd: - dnd += 1 - elif member.status is Status.idle: - idle += 1 - elif member.status is Status.offline: - offline += 1 - - self.bot.stats.gauge("guild.status.online", online) - self.bot.stats.gauge("guild.status.idle", idle) - self.bot.stats.gauge("guild.status.do_not_disturb", dnd) - self.bot.stats.gauge("guild.status.offline", offline) - - @loop(hours=1) - async def update_guild_boost(self) -> None: - """Post the server boost level and tier every hour.""" - await self.bot.wait_until_guild_available() - g = self.bot.get_guild(Guild.id) - self.bot.stats.gauge("boost.amount", g.premium_subscription_count) - self.bot.stats.gauge("boost.tier", g.premium_tier) - - def cog_unload(self) -> None: - """Stop the boost statistic task on unload of the Cog.""" - self.update_guild_boost.stop() - - -def setup(bot: Bot) -> None: - """Load the stats cog.""" - bot.add_cog(Stats(bot)) diff --git a/bot/cogs/sync/__init__.py b/bot/cogs/sync/__init__.py deleted file mode 100644 index fe7df4e9b..000000000 --- a/bot/cogs/sync/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from bot.bot import Bot -from .cog import Sync - - -def setup(bot: Bot) -> None: - """Load the Sync cog.""" - bot.add_cog(Sync(bot)) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py deleted file mode 100644 index 5ace957e7..000000000 --- a/bot/cogs/sync/cog.py +++ /dev/null @@ -1,180 +0,0 @@ -import logging -from typing import Any, Dict - -from discord import Member, Role, User -from discord.ext import commands -from discord.ext.commands import Cog, Context - -from bot import constants -from bot.api import ResponseCodeError -from bot.bot import Bot -from bot.cogs.sync import syncers - -log = logging.getLogger(__name__) - - -class Sync(Cog): - """Captures relevant events and sends them to the site.""" - - def __init__(self, bot: Bot) -> None: - self.bot = bot - self.role_syncer = syncers.RoleSyncer(self.bot) - self.user_syncer = syncers.UserSyncer(self.bot) - - self.bot.loop.create_task(self.sync_guild()) - - async def sync_guild(self) -> None: - """Syncs the roles/users of the guild with the database.""" - await self.bot.wait_until_guild_available() - - guild = self.bot.get_guild(constants.Guild.id) - if guild is None: - return - - for syncer in (self.role_syncer, self.user_syncer): - await syncer.sync(guild) - - async def patch_user(self, user_id: int, json: Dict[str, Any], ignore_404: bool = False) -> None: - """Send a PATCH request to partially update a user in the database.""" - try: - await self.bot.api_client.patch(f"bot/users/{user_id}", json=json) - except ResponseCodeError as e: - if e.response.status != 404: - raise - if not ignore_404: - log.warning("Unable to update user, got 404. Assuming race condition from join event.") - - @Cog.listener() - async def on_guild_role_create(self, role: Role) -> None: - """Adds newly create role to the database table over the API.""" - if role.guild.id != constants.Guild.id: - return - - await self.bot.api_client.post( - 'bot/roles', - json={ - 'colour': role.colour.value, - 'id': role.id, - 'name': role.name, - 'permissions': role.permissions.value, - 'position': role.position, - } - ) - - @Cog.listener() - async def on_guild_role_delete(self, role: Role) -> None: - """Deletes role from the database when it's deleted from the guild.""" - if role.guild.id != constants.Guild.id: - return - - await self.bot.api_client.delete(f'bot/roles/{role.id}') - - @Cog.listener() - async def on_guild_role_update(self, before: Role, after: Role) -> None: - """Syncs role with the database if any of the stored attributes were updated.""" - if after.guild.id != constants.Guild.id: - return - - was_updated = ( - before.name != after.name - or before.colour != after.colour - or before.permissions != after.permissions - or before.position != after.position - ) - - if was_updated: - await self.bot.api_client.put( - f'bot/roles/{after.id}', - json={ - 'colour': after.colour.value, - 'id': after.id, - 'name': after.name, - 'permissions': after.permissions.value, - 'position': after.position, - } - ) - - @Cog.listener() - async def on_member_join(self, member: Member) -> None: - """ - Adds a new user or updates existing user to the database when a member joins the guild. - - If the joining member is a user that is already known to the database (i.e., a user that - previously left), it will update the user's information. If the user is not yet known by - the database, the user is added. - """ - if member.guild.id != constants.Guild.id: - return - - packed = { - 'discriminator': int(member.discriminator), - 'id': member.id, - 'in_guild': True, - 'name': member.name, - 'roles': sorted(role.id for role in member.roles) - } - - got_error = False - - try: - # First try an update of the user to set the `in_guild` field and other - # fields that may have changed since the last time we've seen them. - await self.bot.api_client.put(f'bot/users/{member.id}', json=packed) - - except ResponseCodeError as e: - # If we didn't get 404, something else broke - propagate it up. - if e.response.status != 404: - raise - - got_error = True # yikes - - if got_error: - # If we got `404`, the user is new. Create them. - await self.bot.api_client.post('bot/users', json=packed) - - @Cog.listener() - async def on_member_remove(self, member: Member) -> None: - """Set the in_guild field to False when a member leaves the guild.""" - if member.guild.id != constants.Guild.id: - return - - await self.patch_user(member.id, json={"in_guild": False}) - - @Cog.listener() - async def on_member_update(self, before: Member, after: Member) -> None: - """Update the roles of the member in the database if a change is detected.""" - if after.guild.id != constants.Guild.id: - return - - if before.roles != after.roles: - updated_information = {"roles": sorted(role.id for role in after.roles)} - await self.patch_user(after.id, json=updated_information) - - @Cog.listener() - async def on_user_update(self, before: User, after: User) -> None: - """Update the user information in the database if a relevant change is detected.""" - attrs = ("name", "discriminator") - if any(getattr(before, attr) != getattr(after, attr) for attr in attrs): - updated_information = { - "name": after.name, - "discriminator": int(after.discriminator), - } - # A 404 likely means the user is in another guild. - await self.patch_user(after.id, json=updated_information, ignore_404=True) - - @commands.group(name='sync') - @commands.has_permissions(administrator=True) - async def sync_group(self, ctx: Context) -> None: - """Run synchronizations between the bot and site manually.""" - - @sync_group.command(name='roles') - @commands.has_permissions(administrator=True) - async def sync_roles_command(self, ctx: Context) -> None: - """Manually synchronise the guild's roles with the roles on the site.""" - await self.role_syncer.sync(ctx.guild, ctx) - - @sync_group.command(name='users') - @commands.has_permissions(administrator=True) - async def sync_users_command(self, ctx: Context) -> None: - """Manually synchronise the guild's users with the users on the site.""" - await self.user_syncer.sync(ctx.guild, ctx) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py deleted file mode 100644 index f7ba811bc..000000000 --- a/bot/cogs/sync/syncers.py +++ /dev/null @@ -1,347 +0,0 @@ -import abc -import asyncio -import logging -import typing as t -from collections import namedtuple -from functools import partial - -import discord -from discord import Guild, HTTPException, Member, Message, Reaction, User -from discord.ext.commands import Context - -from bot import constants -from bot.api import ResponseCodeError -from bot.bot import Bot - -log = logging.getLogger(__name__) - -# These objects are declared as namedtuples because tuples are hashable, -# something that we make use of when diffing site roles against guild roles. -_Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions', 'position')) -_User = namedtuple('User', ('id', 'name', 'discriminator', 'roles', 'in_guild')) -_Diff = namedtuple('Diff', ('created', 'updated', 'deleted')) - - -class Syncer(abc.ABC): - """Base class for synchronising the database with objects in the Discord cache.""" - - _CORE_DEV_MENTION = f"<@&{constants.Roles.core_developers}> " - _REACTION_EMOJIS = (constants.Emojis.check_mark, constants.Emojis.cross_mark) - - def __init__(self, bot: Bot) -> None: - self.bot = bot - - @property - @abc.abstractmethod - def name(self) -> str: - """The name of the syncer; used in output messages and logging.""" - raise NotImplementedError # pragma: no cover - - async def _send_prompt(self, message: t.Optional[Message] = None) -> t.Optional[Message]: - """ - Send a prompt to confirm or abort a sync using reactions and return the sent message. - - If a message is given, it is edited to display the prompt and reactions. Otherwise, a new - message is sent to the dev-core channel and mentions the core developers role. If the - channel cannot be retrieved, return None. - """ - log.trace(f"Sending {self.name} sync confirmation prompt.") - - msg_content = ( - f'Possible cache issue while syncing {self.name}s. ' - f'More than {constants.Sync.max_diff} {self.name}s were changed. ' - f'React to confirm or abort the sync.' - ) - - # Send to core developers if it's an automatic sync. - if not message: - log.trace("Message not provided for confirmation; creating a new one in dev-core.") - channel = self.bot.get_channel(constants.Channels.dev_core) - - if not channel: - log.debug("Failed to get the dev-core channel from cache; attempting to fetch it.") - try: - channel = await self.bot.fetch_channel(constants.Channels.dev_core) - except HTTPException: - log.exception( - f"Failed to fetch channel for sending sync confirmation prompt; " - f"aborting {self.name} sync." - ) - return None - - allowed_roles = [discord.Object(constants.Roles.core_developers)] - message = await channel.send( - f"{self._CORE_DEV_MENTION}{msg_content}", - allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) - ) - else: - await message.edit(content=msg_content) - - # Add the initial reactions. - log.trace(f"Adding reactions to {self.name} syncer confirmation prompt.") - for emoji in self._REACTION_EMOJIS: - await message.add_reaction(emoji) - - return message - - def _reaction_check( - self, - author: Member, - message: Message, - reaction: Reaction, - user: t.Union[Member, User] - ) -> bool: - """ - Return True if the `reaction` is a valid confirmation or abort reaction on `message`. - - If the `author` of the prompt is a bot, then a reaction by any core developer will be - considered valid. Otherwise, the author of the reaction (`user`) will have to be the - `author` of the prompt. - """ - # For automatic syncs, check for the core dev role instead of an exact author - has_role = any(constants.Roles.core_developers == role.id for role in user.roles) - return ( - reaction.message.id == message.id - and not user.bot - and (has_role if author.bot else user == author) - and str(reaction.emoji) in self._REACTION_EMOJIS - ) - - async def _wait_for_confirmation(self, author: Member, message: Message) -> bool: - """ - Wait for a confirmation reaction by `author` on `message` and return True if confirmed. - - Uses the `_reaction_check` function to determine if a reaction is valid. - - If there is no reaction within `bot.constants.Sync.confirm_timeout` seconds, return False. - To acknowledge the reaction (or lack thereof), `message` will be edited. - """ - # Preserve the core-dev role mention in the message edits so users aren't confused about - # where notifications came from. - mention = self._CORE_DEV_MENTION if author.bot else "" - - reaction = None - try: - log.trace(f"Waiting for a reaction to the {self.name} syncer confirmation prompt.") - reaction, _ = await self.bot.wait_for( - 'reaction_add', - check=partial(self._reaction_check, author, message), - timeout=constants.Sync.confirm_timeout - ) - except asyncio.TimeoutError: - # reaction will remain none thus sync will be aborted in the finally block below. - log.debug(f"The {self.name} syncer confirmation prompt timed out.") - - if str(reaction) == constants.Emojis.check_mark: - log.trace(f"The {self.name} syncer was confirmed.") - await message.edit(content=f':ok_hand: {mention}{self.name} sync will proceed.') - return True - else: - log.info(f"The {self.name} syncer was aborted or timed out!") - await message.edit( - content=f':warning: {mention}{self.name} sync aborted or timed out!' - ) - return False - - @abc.abstractmethod - async def _get_diff(self, guild: Guild) -> _Diff: - """Return the difference between the cache of `guild` and the database.""" - raise NotImplementedError # pragma: no cover - - @abc.abstractmethod - async def _sync(self, diff: _Diff) -> None: - """Perform the API calls for synchronisation.""" - raise NotImplementedError # pragma: no cover - - async def _get_confirmation_result( - self, - diff_size: int, - author: Member, - message: t.Optional[Message] = None - ) -> t.Tuple[bool, t.Optional[Message]]: - """ - Prompt for confirmation and return a tuple of the result and the prompt message. - - `diff_size` is the size of the diff of the sync. If it is greater than - `bot.constants.Sync.max_diff`, the prompt will be sent. The `author` is the invoked of the - sync and the `message` is an extant message to edit to display the prompt. - - If confirmed or no confirmation was needed, the result is True. The returned message will - either be the given `message` or a new one which was created when sending the prompt. - """ - log.trace(f"Determining if confirmation prompt should be sent for {self.name} syncer.") - if diff_size > constants.Sync.max_diff: - message = await self._send_prompt(message) - if not message: - return False, None # Couldn't get channel. - - confirmed = await self._wait_for_confirmation(author, message) - if not confirmed: - return False, message # Sync aborted. - - return True, message - - async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> None: - """ - Synchronise the database with the cache of `guild`. - - If the differences between the cache and the database are greater than - `bot.constants.Sync.max_diff`, then a confirmation prompt will be sent to the dev-core - channel. The confirmation can be optionally redirect to `ctx` instead. - """ - log.info(f"Starting {self.name} syncer.") - - message = None - author = self.bot.user - if ctx: - message = await ctx.send(f"📊 Synchronising {self.name}s.") - author = ctx.author - - diff = await self._get_diff(guild) - diff_dict = diff._asdict() # Ugly method for transforming the NamedTuple into a dict - totals = {k: len(v) for k, v in diff_dict.items() if v is not None} - diff_size = sum(totals.values()) - - confirmed, message = await self._get_confirmation_result(diff_size, author, message) - if not confirmed: - return - - # Preserve the core-dev role mention in the message edits so users aren't confused about - # where notifications came from. - mention = self._CORE_DEV_MENTION if author.bot else "" - - try: - await self._sync(diff) - except ResponseCodeError as e: - log.exception(f"{self.name} syncer failed!") - - # Don't show response text because it's probably some really long HTML. - results = f"status {e.status}\n```{e.response_json or 'See log output for details'}```" - content = f":x: {mention}Synchronisation of {self.name}s failed: {results}" - else: - results = ", ".join(f"{name} `{total}`" for name, total in totals.items()) - log.info(f"{self.name} syncer finished: {results}.") - content = f":ok_hand: {mention}Synchronisation of {self.name}s complete: {results}" - - if message: - await message.edit(content=content) - - -class RoleSyncer(Syncer): - """Synchronise the database with roles in the cache.""" - - name = "role" - - async def _get_diff(self, guild: Guild) -> _Diff: - """Return the difference of roles between the cache of `guild` and the database.""" - log.trace("Getting the diff for roles.") - roles = await self.bot.api_client.get('bot/roles') - - # Pack DB roles and guild roles into one common, hashable format. - # They're hashable so that they're easily comparable with sets later. - db_roles = {_Role(**role_dict) for role_dict in roles} - guild_roles = { - _Role( - id=role.id, - name=role.name, - colour=role.colour.value, - permissions=role.permissions.value, - position=role.position, - ) - for role in guild.roles - } - - guild_role_ids = {role.id for role in guild_roles} - api_role_ids = {role.id for role in db_roles} - new_role_ids = guild_role_ids - api_role_ids - deleted_role_ids = api_role_ids - guild_role_ids - - # New roles are those which are on the cached guild but not on the - # DB guild, going by the role ID. We need to send them in for creation. - roles_to_create = {role for role in guild_roles if role.id in new_role_ids} - roles_to_update = guild_roles - db_roles - roles_to_create - roles_to_delete = {role for role in db_roles if role.id in deleted_role_ids} - - return _Diff(roles_to_create, roles_to_update, roles_to_delete) - - async def _sync(self, diff: _Diff) -> None: - """Synchronise the database with the role cache of `guild`.""" - log.trace("Syncing created roles...") - for role in diff.created: - await self.bot.api_client.post('bot/roles', json=role._asdict()) - - log.trace("Syncing updated roles...") - for role in diff.updated: - await self.bot.api_client.put(f'bot/roles/{role.id}', json=role._asdict()) - - log.trace("Syncing deleted roles...") - for role in diff.deleted: - await self.bot.api_client.delete(f'bot/roles/{role.id}') - - -class UserSyncer(Syncer): - """Synchronise the database with users in the cache.""" - - name = "user" - - async def _get_diff(self, guild: Guild) -> _Diff: - """Return the difference of users between the cache of `guild` and the database.""" - log.trace("Getting the diff for users.") - users = await self.bot.api_client.get('bot/users') - - # Pack DB roles and guild roles into one common, hashable format. - # They're hashable so that they're easily comparable with sets later. - db_users = { - user_dict['id']: _User( - roles=tuple(sorted(user_dict.pop('roles'))), - **user_dict - ) - for user_dict in users - } - guild_users = { - member.id: _User( - id=member.id, - name=member.name, - discriminator=int(member.discriminator), - roles=tuple(sorted(role.id for role in member.roles)), - in_guild=True - ) - for member in guild.members - } - - users_to_create = set() - users_to_update = set() - - for db_user in db_users.values(): - guild_user = guild_users.get(db_user.id) - if guild_user is not None: - if db_user != guild_user: - users_to_update.add(guild_user) - - elif db_user.in_guild: - # The user is known in the DB but not the guild, and the - # DB currently specifies that the user is a member of the guild. - # This means that the user has left since the last sync. - # Update the `in_guild` attribute of the user on the site - # to signify that the user left. - new_api_user = db_user._replace(in_guild=False) - users_to_update.add(new_api_user) - - new_user_ids = set(guild_users.keys()) - set(db_users.keys()) - for user_id in new_user_ids: - # The user is known on the guild but not on the API. This means - # that the user has joined since the last sync. Create it. - new_user = guild_users[user_id] - users_to_create.add(new_user) - - return _Diff(users_to_create, users_to_update, None) - - async def _sync(self, diff: _Diff) -> None: - """Synchronise the database with the user cache of `guild`.""" - log.trace("Syncing created users...") - for user in diff.created: - await self.bot.api_client.post('bot/users', json=user._asdict()) - - log.trace("Syncing updated users...") - for user in diff.updated: - await self.bot.api_client.put(f'bot/users/{user.id}', json=user._asdict()) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py deleted file mode 100644 index 3d76c5c08..000000000 --- a/bot/cogs/tags.py +++ /dev/null @@ -1,277 +0,0 @@ -import logging -import re -import time -from pathlib import Path -from typing import Callable, Dict, Iterable, List, Optional - -from discord import Colour, Embed, Member -from discord.ext.commands import Cog, Context, group - -from bot import constants -from bot.bot import Bot -from bot.converters import TagNameConverter -from bot.pagination import LinePaginator -from bot.utils.messages import wait_for_deletion - -log = logging.getLogger(__name__) - -TEST_CHANNELS = ( - constants.Channels.bot_commands, - constants.Channels.helpers -) - -REGEX_NON_ALPHABET = re.compile(r"[^a-z]", re.MULTILINE & re.IGNORECASE) -FOOTER_TEXT = f"To show a tag, type {constants.Bot.prefix}tags ." - - -class Tags(Cog): - """Save new tags and fetch existing tags.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.tag_cooldowns = {} - self._cache = self.get_tags() - - @staticmethod - def get_tags() -> dict: - """Get all tags.""" - cache = {} - - base_path = Path("bot", "resources", "tags") - for file in base_path.glob("**/*"): - if file.is_file(): - tag_title = file.stem - tag = { - "title": tag_title, - "embed": { - "description": file.read_text(encoding="utf8"), - }, - "restricted_to": "developers", - "location": f"/bot/{file}" - } - - # Convert to a list to allow negative indexing. - parents = list(file.relative_to(base_path).parents) - if len(parents) > 1: - # -1 would be '.' hence -2 is used as the index. - tag["restricted_to"] = parents[-2].name - - cache[tag_title] = tag - - return cache - - @staticmethod - def check_accessibility(user: Member, tag: dict) -> bool: - """Check if user can access a tag.""" - return tag["restricted_to"].lower() in [role.name.lower() for role in user.roles] - - @staticmethod - def _fuzzy_search(search: str, target: str) -> float: - """A simple scoring algorithm based on how many letters are found / total, with order in mind.""" - current, index = 0, 0 - _search = REGEX_NON_ALPHABET.sub('', search.lower()) - _targets = iter(REGEX_NON_ALPHABET.split(target.lower())) - _target = next(_targets) - try: - while True: - while index < len(_target) and _search[current] == _target[index]: - current += 1 - index += 1 - index, _target = 0, next(_targets) - except (StopIteration, IndexError): - pass - return current / len(_search) * 100 - - def _get_suggestions(self, tag_name: str, thresholds: Optional[List[int]] = None) -> List[str]: - """Return a list of suggested tags.""" - scores: Dict[str, int] = { - tag_title: Tags._fuzzy_search(tag_name, tag['title']) - for tag_title, tag in self._cache.items() - } - - thresholds = thresholds or [100, 90, 80, 70, 60] - - for threshold in thresholds: - suggestions = [ - self._cache[tag_title] - for tag_title, matching_score in scores.items() - if matching_score >= threshold - ] - if suggestions: - return suggestions - - return [] - - def _get_tag(self, tag_name: str) -> list: - """Get a specific tag.""" - found = [self._cache.get(tag_name.lower(), None)] - if not found[0]: - return self._get_suggestions(tag_name) - return found - - def _get_tags_via_content(self, check: Callable[[Iterable], bool], keywords: str, user: Member) -> list: - """ - Search for tags via contents. - - `predicate` will be the built-in any, all, or a custom callable. Must return a bool. - """ - keywords_processed: List[str] = [] - for keyword in keywords.split(','): - keyword_sanitized = keyword.strip().casefold() - if not keyword_sanitized: - # this happens when there are leading / trailing / consecutive comma. - continue - keywords_processed.append(keyword_sanitized) - - if not keywords_processed: - # after sanitizing, we can end up with an empty list, for example when keywords is ',' - # in that case, we simply want to search for such keywords directly instead. - keywords_processed = [keywords] - - matching_tags = [] - for tag in self._cache.values(): - matches = (query in tag['embed']['description'].casefold() for query in keywords_processed) - if self.check_accessibility(user, tag) and check(matches): - matching_tags.append(tag) - - return matching_tags - - async def _send_matching_tags(self, ctx: Context, keywords: str, matching_tags: list) -> None: - """Send the result of matching tags to user.""" - if not matching_tags: - pass - elif len(matching_tags) == 1: - await ctx.send(embed=Embed().from_dict(matching_tags[0]['embed'])) - else: - is_plural = keywords.strip().count(' ') > 0 or keywords.strip().count(',') > 0 - embed = Embed( - title=f"Here are the tags containing the given keyword{'s' * is_plural}:", - description='\n'.join(tag['title'] for tag in matching_tags[:10]) - ) - await LinePaginator.paginate( - sorted(f"**»** {tag['title']}" for tag in matching_tags), - ctx, - embed, - footer_text=FOOTER_TEXT, - empty=False, - max_lines=15 - ) - - @group(name='tags', aliases=('tag', 't'), invoke_without_command=True) - async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: - """Show all known tags, a single tag, or run a subcommand.""" - await ctx.invoke(self.get_command, tag_name=tag_name) - - @tags_group.group(name='search', invoke_without_command=True) - async def search_tag_content(self, ctx: Context, *, keywords: str) -> None: - """ - Search inside tags' contents for tags. Allow searching for multiple keywords separated by comma. - - Only search for tags that has ALL the keywords. - """ - matching_tags = self._get_tags_via_content(all, keywords, ctx.author) - await self._send_matching_tags(ctx, keywords, matching_tags) - - @search_tag_content.command(name='any') - async def search_tag_content_any_keyword(self, ctx: Context, *, keywords: Optional[str] = 'any') -> None: - """ - Search inside tags' contents for tags. Allow searching for multiple keywords separated by comma. - - Search for tags that has ANY of the keywords. - """ - matching_tags = self._get_tags_via_content(any, keywords or 'any', ctx.author) - await self._send_matching_tags(ctx, keywords, matching_tags) - - @tags_group.command(name='get', aliases=('show', 'g')) - async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: - """Get a specified tag, or a list of all tags if no tag is specified.""" - - def _command_on_cooldown(tag_name: str) -> bool: - """ - Check if the command is currently on cooldown, on a per-tag, per-channel basis. - - The cooldown duration is set in constants.py. - """ - now = time.time() - - cooldown_conditions = ( - tag_name - and tag_name in self.tag_cooldowns - and (now - self.tag_cooldowns[tag_name]["time"]) < constants.Cooldowns.tags - and self.tag_cooldowns[tag_name]["channel"] == ctx.channel.id - ) - - if cooldown_conditions: - return True - return False - - if _command_on_cooldown(tag_name): - time_elapsed = time.time() - self.tag_cooldowns[tag_name]["time"] - time_left = constants.Cooldowns.tags - time_elapsed - log.info( - f"{ctx.author} tried to get the '{tag_name}' tag, but the tag is on cooldown. " - f"Cooldown ends in {time_left:.1f} seconds." - ) - return - - if tag_name is not None: - temp_founds = self._get_tag(tag_name) - - founds = [] - - for found_tag in temp_founds: - if self.check_accessibility(ctx.author, found_tag): - founds.append(found_tag) - - if len(founds) == 1: - tag = founds[0] - if ctx.channel.id not in TEST_CHANNELS: - self.tag_cooldowns[tag_name] = { - "time": time.time(), - "channel": ctx.channel.id - } - - self.bot.stats.incr(f"tags.usages.{tag['title'].replace('-', '_')}") - - await wait_for_deletion( - await ctx.send(embed=Embed.from_dict(tag['embed'])), - [ctx.author.id], - client=self.bot - ) - elif founds and len(tag_name) >= 3: - await wait_for_deletion( - await ctx.send( - embed=Embed( - title='Did you mean ...', - description='\n'.join(tag['title'] for tag in founds[:10]) - ) - ), - [ctx.author.id], - client=self.bot - ) - - else: - tags = self._cache.values() - if not tags: - await ctx.send(embed=Embed( - description="**There are no tags in the database!**", - colour=Colour.red() - )) - else: - embed: Embed = Embed(title="**Current tags**") - await LinePaginator.paginate( - sorted( - f"**»** {tag['title']}" for tag in tags - if self.check_accessibility(ctx.author, tag) - ), - ctx, - embed, - footer_text=FOOTER_TEXT, - empty=False, - max_lines=15 - ) - - -def setup(bot: Bot) -> None: - """Load the Tags cog.""" - bot.add_cog(Tags(bot)) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py deleted file mode 100644 index ef979f222..000000000 --- a/bot/cogs/token_remover.py +++ /dev/null @@ -1,182 +0,0 @@ -import base64 -import binascii -import logging -import re -import typing as t - -from discord import Colour, Message, NotFound -from discord.ext.commands import Cog - -from bot import utils -from bot.bot import Bot -from bot.cogs.moderation import ModLog -from bot.constants import Channels, Colours, Event, Icons - -log = logging.getLogger(__name__) - -LOG_MESSAGE = ( - "Censored a seemingly valid token sent by {author} (`{author_id}`) in {channel}, " - "token was `{user_id}.{timestamp}.{hmac}`" -) -DELETION_MESSAGE_TEMPLATE = ( - "Hey {mention}! I noticed you posted a seemingly valid Discord API " - "token in your message and have removed your message. " - "This means that your token has been **compromised**. " - "Please change your token **immediately** at: " - "\n\n" - "Feel free to re-post it with the token removed. " - "If you believe this was a mistake, please let us know!" -) -DISCORD_EPOCH = 1_420_070_400 -TOKEN_EPOCH = 1_293_840_000 - -# Three parts delimited by dots: user ID, creation timestamp, HMAC. -# The HMAC isn't parsed further, but it's in the regex to ensure it at least exists in the string. -# Each part only matches base64 URL-safe characters. -# Padding has never been observed, but the padding character '=' is matched just in case. -TOKEN_RE = re.compile(r"([\w\-=]+)\.([\w\-=]+)\.([\w\-=]+)", re.ASCII) - - -class Token(t.NamedTuple): - """A Discord Bot token.""" - - user_id: str - timestamp: str - hmac: str - - -class TokenRemover(Cog): - """Scans messages for potential discord.py bot tokens and removes them.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - @Cog.listener() - async def on_message(self, msg: Message) -> None: - """ - Check each message for a string that matches Discord's token pattern. - - See: https://discordapp.com/developers/docs/reference#snowflakes - """ - # Ignore DMs; can't delete messages in there anyway. - if not msg.guild or msg.author.bot: - return - - found_token = self.find_token_in_message(msg) - if found_token: - await self.take_action(msg, found_token) - - @Cog.listener() - async def on_message_edit(self, before: Message, after: Message) -> None: - """ - Check each edit for a string that matches Discord's token pattern. - - See: https://discordapp.com/developers/docs/reference#snowflakes - """ - await self.on_message(after) - - async def take_action(self, msg: Message, found_token: Token) -> None: - """Remove the `msg` containing the `found_token` and send a mod log message.""" - self.mod_log.ignore(Event.message_delete, msg.id) - - try: - await msg.delete() - except NotFound: - log.debug(f"Failed to remove token in message {msg.id}: message already deleted.") - return - - await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention)) - - log_message = self.format_log_message(msg, found_token) - log.debug(log_message) - - # Send pretty mod log embed to mod-alerts - await self.mod_log.send_log_message( - icon_url=Icons.token_removed, - colour=Colour(Colours.soft_red), - title="Token removed!", - text=log_message, - thumbnail=msg.author.avatar_url_as(static_format="png"), - channel_id=Channels.mod_alerts, - ) - - self.bot.stats.incr("tokens.removed_tokens") - - @staticmethod - def format_log_message(msg: Message, token: Token) -> str: - """Return the log message to send for `token` being censored in `msg`.""" - return LOG_MESSAGE.format( - author=msg.author, - author_id=msg.author.id, - channel=msg.channel.mention, - user_id=token.user_id, - timestamp=token.timestamp, - hmac='x' * len(token.hmac), - ) - - @classmethod - def find_token_in_message(cls, msg: Message) -> t.Optional[Token]: - """Return a seemingly valid token found in `msg` or `None` if no token is found.""" - # Use finditer rather than search to guard against method calls prematurely returning the - # token check (e.g. `message.channel.send` also matches our token pattern) - for match in TOKEN_RE.finditer(msg.content): - token = Token(*match.groups()) - if cls.is_valid_user_id(token.user_id) and cls.is_valid_timestamp(token.timestamp): - # Short-circuit on first match - return token - - # No matching substring - return - - @staticmethod - def is_valid_user_id(b64_content: str) -> bool: - """ - Check potential token to see if it contains a valid Discord user ID. - - See: https://discordapp.com/developers/docs/reference#snowflakes - """ - b64_content = utils.pad_base64(b64_content) - - try: - decoded_bytes = base64.urlsafe_b64decode(b64_content) - string = decoded_bytes.decode('utf-8') - - # isdigit on its own would match a lot of other Unicode characters, hence the isascii. - return string.isascii() and string.isdigit() - except (binascii.Error, ValueError): - return False - - @staticmethod - def is_valid_timestamp(b64_content: str) -> bool: - """ - Return True if `b64_content` decodes to a valid timestamp. - - If the timestamp is greater than the Discord epoch, it's probably valid. - See: https://i.imgur.com/7WdehGn.png - """ - b64_content = utils.pad_base64(b64_content) - - try: - decoded_bytes = base64.urlsafe_b64decode(b64_content) - timestamp = int.from_bytes(decoded_bytes, byteorder="big") - except (binascii.Error, ValueError) as e: - log.debug(f"Failed to decode token timestamp '{b64_content}': {e}") - return False - - # Seems like newer tokens don't need the epoch added, but add anyway since an upper bound - # is not checked. - if timestamp + TOKEN_EPOCH >= DISCORD_EPOCH: - return True - else: - log.debug(f"Invalid token timestamp '{b64_content}': smaller than Discord epoch") - return False - - -def setup(bot: Bot) -> None: - """Load the TokenRemover cog.""" - bot.add_cog(TokenRemover(bot)) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py deleted file mode 100644 index d96abbd5a..000000000 --- a/bot/cogs/utils.py +++ /dev/null @@ -1,265 +0,0 @@ -import difflib -import logging -import re -import unicodedata -from email.parser import HeaderParser -from io import StringIO -from typing import Tuple, Union - -from discord import Colour, Embed, utils -from discord.ext.commands import BadArgument, Cog, Context, clean_content, command - -from bot.bot import Bot -from bot.constants import Channels, MODERATION_ROLES, STAFF_ROLES -from bot.decorators import in_whitelist, with_role -from bot.pagination import LinePaginator -from bot.utils import messages - -log = logging.getLogger(__name__) - -ZEN_OF_PYTHON = """\ -Beautiful is better than ugly. -Explicit is better than implicit. -Simple is better than complex. -Complex is better than complicated. -Flat is better than nested. -Sparse is better than dense. -Readability counts. -Special cases aren't special enough to break the rules. -Although practicality beats purity. -Errors should never pass silently. -Unless explicitly silenced. -In the face of ambiguity, refuse the temptation to guess. -There should be one-- and preferably only one --obvious way to do it. -Although that way may not be obvious at first unless you're Dutch. -Now is better than never. -Although never is often better than *right* now. -If the implementation is hard to explain, it's a bad idea. -If the implementation is easy to explain, it may be a good idea. -Namespaces are one honking great idea -- let's do more of those! -""" - -ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png" - - -class Utils(Cog): - """A selection of utilities which don't have a clear category.""" - - def __init__(self, bot: Bot): - self.bot = bot - - self.base_pep_url = "http://www.python.org/dev/peps/pep-" - self.base_github_pep_url = "https://raw.githubusercontent.com/python/peps/master/pep-" - - @command(name='pep', aliases=('get_pep', 'p')) - async def pep_command(self, ctx: Context, pep_number: str) -> None: - """Fetches information about a PEP and sends it to the channel.""" - if pep_number.isdigit(): - pep_number = int(pep_number) - else: - await ctx.send_help(ctx.command) - return - - # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs. - if pep_number == 0: - return await self.send_pep_zero(ctx) - - possible_extensions = ['.txt', '.rst'] - found_pep = False - for extension in possible_extensions: - # Attempt to fetch the PEP - pep_url = f"{self.base_github_pep_url}{pep_number:04}{extension}" - log.trace(f"Requesting PEP {pep_number} with {pep_url}") - response = await self.bot.http_session.get(pep_url) - - if response.status == 200: - log.trace("PEP found") - found_pep = True - - pep_content = await response.text() - - # Taken from https://github.com/python/peps/blob/master/pep0/pep.py#L179 - pep_header = HeaderParser().parse(StringIO(pep_content)) - - # Assemble the embed - pep_embed = Embed( - title=f"**PEP {pep_number} - {pep_header['Title']}**", - description=f"[Link]({self.base_pep_url}{pep_number:04})", - ) - - pep_embed.set_thumbnail(url=ICON_URL) - - # Add the interesting information - fields_to_check = ("Status", "Python-Version", "Created", "Type") - for field in fields_to_check: - # Check for a PEP metadata field that is present but has an empty value - # embed field values can't contain an empty string - if pep_header.get(field, ""): - pep_embed.add_field(name=field, value=pep_header[field]) - - elif response.status != 404: - # any response except 200 and 404 is expected - found_pep = True # actually not, but it's easier to display this way - log.trace(f"The user requested PEP {pep_number}, but the response had an unexpected status code: " - f"{response.status}.\n{response.text}") - - error_message = "Unexpected HTTP error during PEP search. Please let us know." - pep_embed = Embed(title="Unexpected error", description=error_message) - pep_embed.colour = Colour.red() - break - - if not found_pep: - log.trace("PEP was not found") - not_found = f"PEP {pep_number} does not exist." - pep_embed = Embed(title="PEP not found", description=not_found) - pep_embed.colour = Colour.red() - - await ctx.message.channel.send(embed=pep_embed) - - @command() - @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES) - async def charinfo(self, ctx: Context, *, characters: str) -> None: - """Shows you information on up to 50 unicode characters.""" - match = re.match(r"<(a?):(\w+):(\d+)>", characters) - if match: - return await messages.send_denial( - ctx, - "**Non-Character Detected**\n" - "Only unicode characters can be processed, but a custom Discord emoji " - "was found. Please remove it and try again." - ) - - if len(characters) > 50: - return await messages.send_denial(ctx, f"Too many characters ({len(characters)}/50)") - - def get_info(char: str) -> Tuple[str, str]: - digit = f"{ord(char):x}" - if len(digit) <= 4: - u_code = f"\\u{digit:>04}" - else: - u_code = f"\\U{digit:>08}" - url = f"https://www.compart.com/en/unicode/U+{digit:>04}" - name = f"[{unicodedata.name(char, '')}]({url})" - info = f"`{u_code.ljust(10)}`: {name} - {utils.escape_markdown(char)}" - return info, u_code - - char_list, raw_list = zip(*(get_info(c) for c in characters)) - embed = Embed().set_author(name="Character Info") - - if len(characters) > 1: - # Maximum length possible is 502 out of 1024, so there's no need to truncate. - embed.add_field(name='Full Raw Text', value=f"`{''.join(raw_list)}`", inline=False) - - await LinePaginator.paginate(char_list, ctx, embed, max_lines=10, max_size=2000, empty=False) - - @command() - async def zen(self, ctx: Context, *, search_value: Union[int, str, None] = None) -> None: - """ - Show the Zen of Python. - - Without any arguments, the full Zen will be produced. - If an integer is provided, the line with that index will be produced. - If a string is provided, the line which matches best will be produced. - """ - embed = Embed( - colour=Colour.blurple(), - title="The Zen of Python", - description=ZEN_OF_PYTHON - ) - - if search_value is None: - embed.title += ", by Tim Peters" - await ctx.send(embed=embed) - return - - zen_lines = ZEN_OF_PYTHON.splitlines() - - # handle if it's an index int - if isinstance(search_value, int): - upper_bound = len(zen_lines) - 1 - lower_bound = -1 * upper_bound - if not (lower_bound <= search_value <= upper_bound): - raise BadArgument(f"Please provide an index between {lower_bound} and {upper_bound}.") - - embed.title += f" (line {search_value % len(zen_lines)}):" - embed.description = zen_lines[search_value] - await ctx.send(embed=embed) - return - - # Try to handle first exact word due difflib.SequenceMatched may use some other similar word instead - # exact word. - for i, line in enumerate(zen_lines): - for word in line.split(): - if word.lower() == search_value.lower(): - embed.title += f" (line {i}):" - embed.description = line - await ctx.send(embed=embed) - return - - # handle if it's a search string and not exact word - matcher = difflib.SequenceMatcher(None, search_value.lower()) - - best_match = "" - match_index = 0 - best_ratio = 0 - - for index, line in enumerate(zen_lines): - matcher.set_seq2(line.lower()) - - # the match ratio needs to be adjusted because, naturally, - # longer lines will have worse ratios than shorter lines when - # fuzzy searching for keywords. this seems to work okay. - adjusted_ratio = (len(line) - 5) ** 0.5 * matcher.ratio() - - if adjusted_ratio > best_ratio: - best_ratio = adjusted_ratio - best_match = line - match_index = index - - if not best_match: - raise BadArgument("I didn't get a match! Please try again with a different search term.") - - embed.title += f" (line {match_index}):" - embed.description = best_match - await ctx.send(embed=embed) - - @command(aliases=("poll",)) - @with_role(*MODERATION_ROLES) - async def vote(self, ctx: Context, title: clean_content(fix_channel_mentions=True), *options: str) -> None: - """ - Build a quick voting poll with matching reactions with the provided options. - - A maximum of 20 options can be provided, as Discord supports a max of 20 - reactions on a single message. - """ - if len(title) > 256: - raise BadArgument("The title cannot be longer than 256 characters.") - if len(options) < 2: - raise BadArgument("Please provide at least 2 options.") - if len(options) > 20: - raise BadArgument("I can only handle 20 options!") - - codepoint_start = 127462 # represents "regional_indicator_a" unicode value - options = {chr(i): f"{chr(i)} - {v}" for i, v in enumerate(options, start=codepoint_start)} - embed = Embed(title=title, description="\n".join(options.values())) - message = await ctx.send(embed=embed) - for reaction in options: - await message.add_reaction(reaction) - - async def send_pep_zero(self, ctx: Context) -> None: - """Send information about PEP 0.""" - pep_embed = Embed( - title="**PEP 0 - Index of Python Enhancement Proposals (PEPs)**", - description="[Link](https://www.python.org/dev/peps/)" - ) - pep_embed.set_thumbnail(url=ICON_URL) - pep_embed.add_field(name="Status", value="Active") - pep_embed.add_field(name="Created", value="13-Jul-2000") - pep_embed.add_field(name="Type", value="Informational") - - await ctx.send(embed=pep_embed) - - -def setup(bot: Bot) -> None: - """Load the Utils cog.""" - bot.add_cog(Utils(bot)) diff --git a/bot/cogs/utils/__init__.py b/bot/cogs/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bot/cogs/utils/bot.py b/bot/cogs/utils/bot.py new file mode 100644 index 000000000..71ed54f60 --- /dev/null +++ b/bot/cogs/utils/bot.py @@ -0,0 +1,385 @@ +import ast +import logging +import re +import time +from typing import Optional, Tuple + +from discord import Embed, Message, RawMessageUpdateEvent, TextChannel +from discord.ext.commands import Cog, Context, command, group + +from bot.bot import Bot +from bot.cogs.filters.token_remover import TokenRemover +from bot.constants import Categories, Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs +from bot.decorators import with_role +from bot.utils.messages import wait_for_deletion + +log = logging.getLogger(__name__) + +RE_MARKDOWN = re.compile(r'([*_~`|>])') + + +class BotCog(Cog, name="Bot"): + """Bot information commands.""" + + def __init__(self, bot: Bot): + self.bot = bot + + # Stores allowed channels plus epoch time since last call. + self.channel_cooldowns = { + Channels.python_discussion: 0, + } + + # These channels will also work, but will not be subject to cooldown + self.channel_whitelist = ( + Channels.bot_commands, + ) + + # Stores improperly formatted Python codeblock message ids and the corresponding bot message + self.codeblock_message_ids = {} + + @group(invoke_without_command=True, name="bot", hidden=True) + @with_role(Roles.verified) + async def botinfo_group(self, ctx: Context) -> None: + """Bot informational commands.""" + await ctx.send_help(ctx.command) + + @botinfo_group.command(name='about', aliases=('info',), hidden=True) + @with_role(Roles.verified) + async def about_command(self, ctx: Context) -> None: + """Get information about the bot.""" + embed = Embed( + description="A utility bot designed just for the Python server! Try `!help` for more info.", + url="https://github.com/python-discord/bot" + ) + + embed.add_field(name="Total Users", value=str(len(self.bot.get_guild(Guild.id).members))) + embed.set_author( + name="Python Bot", + url="https://github.com/python-discord/bot", + icon_url=URLs.bot_avatar + ) + + await ctx.send(embed=embed) + + @command(name='echo', aliases=('print',)) + @with_role(*MODERATION_ROLES) + async def echo_command(self, ctx: Context, channel: Optional[TextChannel], *, text: str) -> None: + """Repeat the given message in either a specified channel or the current channel.""" + if channel is None: + await ctx.send(text) + else: + await channel.send(text) + + @command(name='embed') + @with_role(*MODERATION_ROLES) + async def embed_command(self, ctx: Context, channel: Optional[TextChannel], *, text: str) -> None: + """Send the input within an embed to either a specified channel or the current channel.""" + embed = Embed(description=text) + + if channel is None: + await ctx.send(embed=embed) + else: + await channel.send(embed=embed) + + def codeblock_stripping(self, msg: str, bad_ticks: bool) -> Optional[Tuple[Tuple[str, ...], str]]: + """ + Strip msg in order to find Python code. + + Tries to strip out Python code out of msg and returns the stripped block or + None if the block is a valid Python codeblock. + """ + if msg.count("\n") >= 3: + # Filtering valid Python codeblocks and exiting if a valid Python codeblock is found. + if re.search("```(?:py|python)\n(.*?)```", msg, re.IGNORECASE | re.DOTALL) and not bad_ticks: + log.trace( + "Someone wrote a message that was already a " + "valid Python syntax highlighted code block. No action taken." + ) + return None + + else: + # Stripping backticks from every line of the message. + log.trace(f"Stripping backticks from message.\n\n{msg}\n\n") + content = "" + for line in msg.splitlines(keepends=True): + content += line.strip("`") + + content = content.strip() + + # Remove "Python" or "Py" from start of the message if it exists. + log.trace(f"Removing 'py' or 'python' from message.\n\n{content}\n\n") + pycode = False + if content.lower().startswith("python"): + content = content[6:] + pycode = True + elif content.lower().startswith("py"): + content = content[2:] + pycode = True + + if pycode: + content = content.splitlines(keepends=True) + + # Check if there might be code in the first line, and preserve it. + first_line = content[0] + if " " in content[0]: + first_space = first_line.index(" ") + content[0] = first_line[first_space:] + content = "".join(content) + + # If there's no code we can just get rid of the first line. + else: + content = "".join(content[1:]) + + # Strip it again to remove any leading whitespace. This is neccessary + # if the first line of the message looked like ```python + old = content.strip() + + # Strips REPL code out of the message if there is any. + content, repl_code = self.repl_stripping(old) + if old != content: + return (content, old), repl_code + + # Try to apply indentation fixes to the code. + content = self.fix_indentation(content) + + # Check if the code contains backticks, if it does ignore the message. + if "`" in content: + log.trace("Detected ` inside the code, won't reply") + return None + else: + log.trace(f"Returning message.\n\n{content}\n\n") + return (content,), repl_code + + def fix_indentation(self, msg: str) -> str: + """Attempts to fix badly indented code.""" + def unindent(code: str, skip_spaces: int = 0) -> str: + """Unindents all code down to the number of spaces given in skip_spaces.""" + final = "" + current = code[0] + leading_spaces = 0 + + # Get numbers of spaces before code in the first line. + while current == " ": + current = code[leading_spaces + 1] + leading_spaces += 1 + leading_spaces -= skip_spaces + + # If there are any, remove that number of spaces from every line. + if leading_spaces > 0: + for line in code.splitlines(keepends=True): + line = line[leading_spaces:] + final += line + return final + else: + return code + + # Apply fix for "all lines are overindented" case. + msg = unindent(msg) + + # If the first line does not end with a colon, we can be + # certain the next line will be on the same indentation level. + # + # If it does end with a colon, we will need to indent all successive + # lines one additional level. + first_line = msg.splitlines()[0] + code = "".join(msg.splitlines(keepends=True)[1:]) + if not first_line.endswith(":"): + msg = f"{first_line}\n{unindent(code)}" + else: + msg = f"{first_line}\n{unindent(code, 4)}" + return msg + + def repl_stripping(self, msg: str) -> Tuple[str, bool]: + """ + Strip msg in order to extract Python code out of REPL output. + + Tries to strip out REPL Python code out of msg and returns the stripped msg. + + Returns True for the boolean if REPL code was found in the input msg. + """ + final = "" + for line in msg.splitlines(keepends=True): + if line.startswith(">>>") or line.startswith("..."): + final += line[4:] + log.trace(f"Formatted: \n\n{msg}\n\n to \n\n{final}\n\n") + if not final: + log.trace(f"Found no REPL code in \n\n{msg}\n\n") + return msg, False + else: + log.trace(f"Found REPL code in \n\n{msg}\n\n") + return final.rstrip(), True + + def has_bad_ticks(self, msg: Message) -> bool: + """Check to see if msg contains ticks that aren't '`'.""" + not_backticks = [ + "'''", '"""', "\u00b4\u00b4\u00b4", "\u2018\u2018\u2018", "\u2019\u2019\u2019", + "\u2032\u2032\u2032", "\u201c\u201c\u201c", "\u201d\u201d\u201d", "\u2033\u2033\u2033", + "\u3003\u3003\u3003" + ] + + return msg.content[:3] in not_backticks + + @Cog.listener() + async def on_message(self, msg: Message) -> None: + """ + Detect poorly formatted Python code in new messages. + + If poorly formatted code is detected, send the user a helpful message explaining how to do + properly formatted Python syntax highlighting codeblocks. + """ + is_help_channel = ( + getattr(msg.channel, "category", None) + and msg.channel.category.id in (Categories.help_available, Categories.help_in_use) + ) + parse_codeblock = ( + ( + is_help_channel + or msg.channel.id in self.channel_cooldowns + or msg.channel.id in self.channel_whitelist + ) + and not msg.author.bot + and len(msg.content.splitlines()) > 3 + and not TokenRemover.find_token_in_message(msg) + ) + + if parse_codeblock: # no token in the msg + on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300 + if not on_cooldown or DEBUG_MODE: + try: + if self.has_bad_ticks(msg): + ticks = msg.content[:3] + content = self.codeblock_stripping(f"```{msg.content[3:-3]}```", True) + if content is None: + return + + content, repl_code = content + + if len(content) == 2: + content = content[1] + else: + content = content[0] + + space_left = 204 + if len(content) >= space_left: + current_length = 0 + lines_walked = 0 + for line in content.splitlines(keepends=True): + if current_length + len(line) > space_left or lines_walked == 10: + break + current_length += len(line) + lines_walked += 1 + content = content[:current_length] + "#..." + content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + howto = ( + "It looks like you are trying to paste code into this channel.\n\n" + "You seem to be using the wrong symbols to indicate where the codeblock should start. " + f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n" + "**Here is an example of how it should look:**\n" + f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" + f"```python\n{content}\n```" + ) + + else: + howto = "" + content = self.codeblock_stripping(msg.content, False) + if content is None: + return + + content, repl_code = content + # Attempts to parse the message into an AST node. + # Invalid Python code will raise a SyntaxError. + tree = ast.parse(content[0]) + + # Multiple lines of single words could be interpreted as expressions. + # This check is to avoid all nodes being parsed as expressions. + # (e.g. words over multiple lines) + if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: + # Shorten the code to 10 lines and/or 204 characters. + space_left = 204 + if content and repl_code: + content = content[1] + else: + content = content[0] + + if len(content) >= space_left: + current_length = 0 + lines_walked = 0 + for line in content.splitlines(keepends=True): + if current_length + len(line) > space_left or lines_walked == 10: + break + current_length += len(line) + lines_walked += 1 + content = content[:current_length] + "#..." + + content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + howto += ( + "It looks like you're trying to paste code into this channel.\n\n" + "Discord has support for Markdown, which allows you to post code with full " + "syntax highlighting. Please use these whenever you paste code, as this " + "helps improve the legibility and makes it easier for us to help you.\n\n" + f"**To do this, use the following method:**\n" + f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" + f"```python\n{content}\n```" + ) + + log.debug(f"{msg.author} posted something that needed to be put inside python code " + "blocks. Sending the user some instructions.") + else: + log.trace("The code consists only of expressions, not sending instructions") + + if howto != "": + # Increase amount of codeblock correction in stats + self.bot.stats.incr("codeblock_corrections") + howto_embed = Embed(description=howto) + bot_message = await msg.channel.send(f"Hey {msg.author.mention}!", embed=howto_embed) + self.codeblock_message_ids[msg.id] = bot_message.id + + self.bot.loop.create_task( + wait_for_deletion(bot_message, user_ids=(msg.author.id,), client=self.bot) + ) + else: + return + + if msg.channel.id not in self.channel_whitelist: + self.channel_cooldowns[msg.channel.id] = time.time() + + except SyntaxError: + log.trace( + f"{msg.author} posted in a help channel, and when we tried to parse it as Python code, " + "ast.parse raised a SyntaxError. This probably just means it wasn't Python code. " + f"The message that was posted was:\n\n{msg.content}\n\n" + ) + + @Cog.listener() + async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: + """Check to see if an edited message (previously called out) still contains poorly formatted code.""" + if ( + # Checks to see if the message was called out by the bot + payload.message_id not in self.codeblock_message_ids + # Makes sure that there is content in the message + or payload.data.get("content") is None + # Makes sure there's a channel id in the message payload + or payload.data.get("channel_id") is None + ): + return + + # Retrieve channel and message objects for use later + channel = self.bot.get_channel(int(payload.data.get("channel_id"))) + user_message = await channel.fetch_message(payload.message_id) + + # Checks to see if the user has corrected their codeblock. If it's fixed, has_fixed_codeblock will be None + has_fixed_codeblock = self.codeblock_stripping(payload.data.get("content"), self.has_bad_ticks(user_message)) + + # If the message is fixed, delete the bot message and the entry from the id dictionary + if has_fixed_codeblock is None: + bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) + await bot_message.delete() + del self.codeblock_message_ids[payload.message_id] + log.trace("User's incorrect code block has been fixed. Removing bot formatting message.") + + +def setup(bot: Bot) -> None: + """Load the Bot cog.""" + bot.add_cog(BotCog(bot)) diff --git a/bot/cogs/utils/clean.py b/bot/cogs/utils/clean.py new file mode 100644 index 000000000..f436e531a --- /dev/null +++ b/bot/cogs/utils/clean.py @@ -0,0 +1,272 @@ +import logging +import random +import re +from typing import Iterable, Optional + +from discord import Colour, Embed, Message, TextChannel, User +from discord.ext import commands +from discord.ext.commands import Cog, Context, group + +from bot.bot import Bot +from bot.cogs.moderation import ModLog +from bot.constants import ( + Channels, CleanMessages, Colours, Event, Icons, MODERATION_ROLES, NEGATIVE_REPLIES +) +from bot.decorators import with_role + +log = logging.getLogger(__name__) + + +class Clean(Cog): + """ + A cog that allows messages to be deleted in bulk, while applying various filters. + + You can delete messages sent by a specific user, messages sent by bots, all messages, or messages that match a + specific regular expression. + + The deleted messages are saved and uploaded to the database via an API endpoint, and a URL is returned which can be + used to view the messages in the Discord dark theme style. + """ + + def __init__(self, bot: Bot): + self.bot = bot + self.cleaning = False + + @property + def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + async def _clean_messages( + self, + amount: int, + ctx: Context, + channels: Iterable[TextChannel], + bots_only: bool = False, + user: User = None, + regex: Optional[str] = None, + until_message: Optional[Message] = None, + ) -> None: + """A helper function that does the actual message cleaning.""" + def predicate_bots_only(message: Message) -> bool: + """Return True if the message was sent by a bot.""" + return message.author.bot + + def predicate_specific_user(message: Message) -> bool: + """Return True if the message was sent by the user provided in the _clean_messages call.""" + return message.author == user + + def predicate_regex(message: Message) -> bool: + """Check if the regex provided in _clean_messages matches the message content or any embed attributes.""" + content = [message.content] + + # Add the content for all embed attributes + for embed in message.embeds: + content.append(embed.title) + content.append(embed.description) + content.append(embed.footer.text) + content.append(embed.author.name) + for field in embed.fields: + content.append(field.name) + content.append(field.value) + + # Get rid of empty attributes and turn it into a string + content = [attr for attr in content if attr] + content = "\n".join(content) + + # Now let's see if there's a regex match + if not content: + return False + else: + return bool(re.search(regex.lower(), content.lower())) + + # Is this an acceptable amount of messages to clean? + if amount > CleanMessages.message_limit: + embed = Embed( + color=Colour(Colours.soft_red), + title=random.choice(NEGATIVE_REPLIES), + description=f"You cannot clean more than {CleanMessages.message_limit} messages." + ) + await ctx.send(embed=embed) + return + + # Are we already performing a clean? + if self.cleaning: + embed = Embed( + color=Colour(Colours.soft_red), + title=random.choice(NEGATIVE_REPLIES), + description="Please wait for the currently ongoing clean operation to complete." + ) + await ctx.send(embed=embed) + return + + # Set up the correct predicate + if bots_only: + predicate = predicate_bots_only # Delete messages from bots + elif user: + predicate = predicate_specific_user # Delete messages from specific user + elif regex: + predicate = predicate_regex # Delete messages that match regex + else: + predicate = None # Delete all messages + + # Default to using the invoking context's channel + if not channels: + channels = [ctx.channel] + + # Delete the invocation first + self.mod_log.ignore(Event.message_delete, ctx.message.id) + await ctx.message.delete() + + messages = [] + message_ids = [] + self.cleaning = True + + # Find the IDs of the messages to delete. IDs are needed in order to ignore mod log events. + for channel in channels: + async for message in channel.history(limit=amount): + + # If at any point the cancel command is invoked, we should stop. + if not self.cleaning: + return + + # If we are looking for specific message. + if until_message: + + # we could use ID's here however in case if the message we are looking for gets deleted, + # we won't have a way to figure that out thus checking for datetime should be more reliable + if message.created_at < until_message.created_at: + # means we have found the message until which we were supposed to be deleting. + break + + # Since we will be using `delete_messages` method of a TextChannel and we need message objects to + # use it as well as to send logs we will start appending messages here instead adding them from + # purge. + messages.append(message) + + # If the message passes predicate, let's save it. + if predicate is None or predicate(message): + message_ids.append(message.id) + + self.cleaning = False + + # Now let's delete the actual messages with purge. + self.mod_log.ignore(Event.message_delete, *message_ids) + for channel in channels: + if until_message: + for i in range(0, len(messages), 100): + # while purge automatically handles the amount of messages + # delete_messages only allows for up to 100 messages at once + # thus we need to paginate the amount to always be <= 100 + await channel.delete_messages(messages[i:i + 100]) + else: + messages += await channel.purge(limit=amount, check=predicate) + + # Reverse the list to restore chronological order + if messages: + messages = reversed(messages) + log_url = await self.mod_log.upload_log(messages, ctx.author.id) + else: + # Can't build an embed, nothing to clean! + embed = Embed( + color=Colour(Colours.soft_red), + description="No matching messages could be found." + ) + await ctx.send(embed=embed, delete_after=10) + return + + # Build the embed and send it + target_channels = ", ".join(channel.mention for channel in channels) + + message = ( + f"**{len(message_ids)}** messages deleted in {target_channels} by **{ctx.author.name}**\n\n" + f"A log of the deleted messages can be found [here]({log_url})." + ) + + await self.mod_log.send_log_message( + icon_url=Icons.message_bulk_delete, + colour=Colour(Colours.soft_red), + title="Bulk message delete", + text=message, + channel_id=Channels.mod_log, + ) + + @group(invoke_without_command=True, name="clean", aliases=["purge"]) + @with_role(*MODERATION_ROLES) + async def clean_group(self, ctx: Context) -> None: + """Commands for cleaning messages in channels.""" + await ctx.send_help(ctx.command) + + @clean_group.command(name="user", aliases=["users"]) + @with_role(*MODERATION_ROLES) + async def clean_user( + self, + ctx: Context, + user: User, + amount: Optional[int] = 10, + channels: commands.Greedy[TextChannel] = None + ) -> None: + """Delete messages posted by the provided user, stop cleaning after traversing `amount` messages.""" + await self._clean_messages(amount, ctx, user=user, channels=channels) + + @clean_group.command(name="all", aliases=["everything"]) + @with_role(*MODERATION_ROLES) + async def clean_all( + self, + ctx: Context, + amount: Optional[int] = 10, + channels: commands.Greedy[TextChannel] = None + ) -> None: + """Delete all messages, regardless of poster, stop cleaning after traversing `amount` messages.""" + await self._clean_messages(amount, ctx, channels=channels) + + @clean_group.command(name="bots", aliases=["bot"]) + @with_role(*MODERATION_ROLES) + async def clean_bots( + self, + ctx: Context, + amount: Optional[int] = 10, + channels: commands.Greedy[TextChannel] = None + ) -> None: + """Delete all messages posted by a bot, stop cleaning after traversing `amount` messages.""" + await self._clean_messages(amount, ctx, bots_only=True, channels=channels) + + @clean_group.command(name="regex", aliases=["word", "expression"]) + @with_role(*MODERATION_ROLES) + async def clean_regex( + self, + ctx: Context, + regex: str, + amount: Optional[int] = 10, + channels: commands.Greedy[TextChannel] = None + ) -> None: + """Delete all messages that match a certain regex, stop cleaning after traversing `amount` messages.""" + await self._clean_messages(amount, ctx, regex=regex, channels=channels) + + @clean_group.command(name="message", aliases=["messages"]) + @with_role(*MODERATION_ROLES) + async def clean_message(self, ctx: Context, message: Message) -> None: + """Delete all messages until certain message, stop cleaning after hitting the `message`.""" + await self._clean_messages( + CleanMessages.message_limit, + ctx, + channels=[message.channel], + until_message=message + ) + + @clean_group.command(name="stop", aliases=["cancel", "abort"]) + @with_role(*MODERATION_ROLES) + async def clean_cancel(self, ctx: Context) -> None: + """If there is an ongoing cleaning process, attempt to immediately cancel it.""" + self.cleaning = False + + embed = Embed( + color=Colour.blurple(), + description="Clean interrupted." + ) + await ctx.send(embed=embed, delete_after=10) + + +def setup(bot: Bot) -> None: + """Load the Clean cog.""" + bot.add_cog(Clean(bot)) diff --git a/bot/cogs/utils/eval.py b/bot/cogs/utils/eval.py new file mode 100644 index 000000000..eb8bfb1cf --- /dev/null +++ b/bot/cogs/utils/eval.py @@ -0,0 +1,202 @@ +import contextlib +import inspect +import logging +import pprint +import re +import textwrap +import traceback +from io import StringIO +from typing import Any, Optional, Tuple + +import discord +from discord.ext.commands import Cog, Context, group + +from bot.bot import Bot +from bot.constants import Roles +from bot.decorators import with_role +from bot.interpreter import Interpreter + +log = logging.getLogger(__name__) + + +class CodeEval(Cog): + """Owner and admin feature that evaluates code and returns the result to the channel.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.env = {} + self.ln = 0 + self.stdout = StringIO() + + self.interpreter = Interpreter(bot) + + def _format(self, inp: str, out: Any) -> Tuple[str, Optional[discord.Embed]]: + """Format the eval output into a string & attempt to format it into an Embed.""" + self._ = out + + res = "" + + # Erase temp input we made + if inp.startswith("_ = "): + inp = inp[4:] + + # Get all non-empty lines + lines = [line for line in inp.split("\n") if line.strip()] + if len(lines) != 1: + lines += [""] + + # Create the input dialog + for i, line in enumerate(lines): + if i == 0: + # Start dialog + start = f"In [{self.ln}]: " + + else: + # Indent the 3 dots correctly; + # Normally, it's something like + # In [X]: + # ...: + # + # But if it's + # In [XX]: + # ...: + # + # You can see it doesn't look right. + # This code simply indents the dots + # far enough to align them. + # we first `str()` the line number + # then we get the length + # and use `str.rjust()` + # to indent it. + start = "...: ".rjust(len(str(self.ln)) + 7) + + if i == len(lines) - 2: + if line.startswith("return"): + line = line[6:].strip() + + # Combine everything + res += (start + line + "\n") + + self.stdout.seek(0) + text = self.stdout.read() + self.stdout.close() + self.stdout = StringIO() + + if text: + res += (text + "\n") + + if out is None: + # No output, return the input statement + return (res, None) + + res += f"Out[{self.ln}]: " + + if isinstance(out, discord.Embed): + # We made an embed? Send that as embed + res += "" + res = (res, out) + + else: + if (isinstance(out, str) and out.startswith("Traceback (most recent call last):\n")): + # Leave out the traceback message + out = "\n" + "\n".join(out.split("\n")[1:]) + + if isinstance(out, str): + pretty = out + else: + pretty = pprint.pformat(out, compact=True, width=60) + + if pretty != str(out): + # We're using the pretty version, start on the next line + res += "\n" + + if pretty.count("\n") > 20: + # Text too long, shorten + li = pretty.split("\n") + + pretty = ("\n".join(li[:3]) # First 3 lines + + "\n ...\n" # Ellipsis to indicate removed lines + + "\n".join(li[-3:])) # last 3 lines + + # Add the output + res += pretty + res = (res, None) + + return res # Return (text, embed) + + async def _eval(self, ctx: Context, code: str) -> Optional[discord.Message]: + """Eval the input code string & send an embed to the invoking context.""" + self.ln += 1 + + if code.startswith("exit"): + self.ln = 0 + self.env = {} + return await ctx.send("```Reset history!```") + + env = { + "message": ctx.message, + "author": ctx.message.author, + "channel": ctx.channel, + "guild": ctx.guild, + "ctx": ctx, + "self": self, + "bot": self.bot, + "inspect": inspect, + "discord": discord, + "contextlib": contextlib + } + + self.env.update(env) + + # Ignore this code, it works + code_ = """ +async def func(): # (None,) -> Any + try: + with contextlib.redirect_stdout(self.stdout): +{0} + if '_' in locals(): + if inspect.isawaitable(_): + _ = await _ + return _ + finally: + self.env.update(locals()) +""".format(textwrap.indent(code, ' ')) + + try: + exec(code_, self.env) # noqa: B102,S102 + func = self.env['func'] + res = await func() + + except Exception: + res = traceback.format_exc() + + out, embed = self._format(code, res) + await ctx.send(f"```py\n{out}```", embed=embed) + + @group(name='internal', aliases=('int',)) + @with_role(Roles.owners, Roles.admins) + async def internal_group(self, ctx: Context) -> None: + """Internal commands. Top secret!""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @internal_group.command(name='eval', aliases=('e',)) + @with_role(Roles.admins, Roles.owners) + async def eval(self, ctx: Context, *, code: str) -> None: + """Run eval in a REPL-like format.""" + code = code.strip("`") + if re.match('py(thon)?\n', code): + code = "\n".join(code.split("\n")[1:]) + + if not re.search( # Check if it's an expression + r"^(return|import|for|while|def|class|" + r"from|exit|[a-zA-Z0-9]+\s*=)", code, re.M) and len( + code.split("\n")) == 1: + code = "_ = " + code + + await self._eval(ctx, code) + + +def setup(bot: Bot) -> None: + """Load the CodeEval cog.""" + bot.add_cog(CodeEval(bot)) diff --git a/bot/cogs/utils/extensions.py b/bot/cogs/utils/extensions.py new file mode 100644 index 000000000..365f198ff --- /dev/null +++ b/bot/cogs/utils/extensions.py @@ -0,0 +1,236 @@ +import functools +import logging +import typing as t +from enum import Enum +from pkgutil import iter_modules + +from discord import Colour, Embed +from discord.ext import commands +from discord.ext.commands import Context, group + +from bot.bot import Bot +from bot.constants import Emojis, MODERATION_ROLES, Roles, URLs +from bot.pagination import LinePaginator +from bot.utils.checks import with_role_check + +log = logging.getLogger(__name__) + +UNLOAD_BLACKLIST = {"bot.cogs.extensions", "bot.cogs.modlog"} +EXTENSIONS = frozenset( + ext.name + for ext in iter_modules(("bot/cogs",), "bot.cogs.") + if ext.name[-1] != "_" +) + + +class Action(Enum): + """Represents an action to perform on an extension.""" + + # Need to be partial otherwise they are considered to be function definitions. + LOAD = functools.partial(Bot.load_extension) + UNLOAD = functools.partial(Bot.unload_extension) + RELOAD = functools.partial(Bot.reload_extension) + + +class Extension(commands.Converter): + """ + Fully qualify the name of an extension and ensure it exists. + + The * and ** values bypass this when used with the reload command. + """ + + async def convert(self, ctx: Context, argument: str) -> str: + """Fully qualify the name of an extension and ensure it exists.""" + # Special values to reload all extensions + if argument == "*" or argument == "**": + return argument + + argument = argument.lower() + + if "." not in argument: + argument = f"bot.cogs.{argument}" + + if argument in EXTENSIONS: + return argument + else: + raise commands.BadArgument(f":x: Could not find the extension `{argument}`.") + + +class Extensions(commands.Cog): + """Extension management commands.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @group(name="extensions", aliases=("ext", "exts", "c", "cogs"), invoke_without_command=True) + async def extensions_group(self, ctx: Context) -> None: + """Load, unload, reload, and list loaded extensions.""" + await ctx.send_help(ctx.command) + + @extensions_group.command(name="load", aliases=("l",)) + async def load_command(self, ctx: Context, *extensions: Extension) -> None: + r""" + Load extensions given their fully qualified or unqualified names. + + If '\*' or '\*\*' is given as the name, all unloaded extensions will be loaded. + """ # noqa: W605 + if not extensions: + await ctx.send_help(ctx.command) + return + + if "*" in extensions or "**" in extensions: + extensions = set(EXTENSIONS) - set(self.bot.extensions.keys()) + + msg = self.batch_manage(Action.LOAD, *extensions) + await ctx.send(msg) + + @extensions_group.command(name="unload", aliases=("ul",)) + async def unload_command(self, ctx: Context, *extensions: Extension) -> None: + r""" + Unload currently loaded extensions given their fully qualified or unqualified names. + + If '\*' or '\*\*' is given as the name, all loaded extensions will be unloaded. + """ # noqa: W605 + if not extensions: + await ctx.send_help(ctx.command) + return + + blacklisted = "\n".join(UNLOAD_BLACKLIST & set(extensions)) + + if blacklisted: + msg = f":x: The following extension(s) may not be unloaded:```{blacklisted}```" + else: + if "*" in extensions or "**" in extensions: + extensions = set(self.bot.extensions.keys()) - UNLOAD_BLACKLIST + + msg = self.batch_manage(Action.UNLOAD, *extensions) + + await ctx.send(msg) + + @extensions_group.command(name="reload", aliases=("r",)) + async def reload_command(self, ctx: Context, *extensions: Extension) -> None: + r""" + Reload extensions given their fully qualified or unqualified names. + + If an extension fails to be reloaded, it will be rolled-back to the prior working state. + + If '\*' is given as the name, all currently loaded extensions will be reloaded. + If '\*\*' is given as the name, all extensions, including unloaded ones, will be reloaded. + """ # noqa: W605 + if not extensions: + await ctx.send_help(ctx.command) + return + + if "**" in extensions: + extensions = EXTENSIONS + elif "*" in extensions: + extensions = set(self.bot.extensions.keys()) | set(extensions) + extensions.remove("*") + + msg = self.batch_manage(Action.RELOAD, *extensions) + + await ctx.send(msg) + + @extensions_group.command(name="list", aliases=("all",)) + async def list_command(self, ctx: Context) -> None: + """ + Get a list of all extensions, including their loaded status. + + Grey indicates that the extension is unloaded. + Green indicates that the extension is currently loaded. + """ + embed = Embed() + lines = [] + + embed.colour = Colour.blurple() + embed.set_author( + name="Extensions List", + url=URLs.github_bot_repo, + icon_url=URLs.bot_avatar + ) + + for ext in sorted(list(EXTENSIONS)): + if ext in self.bot.extensions: + status = Emojis.status_online + else: + status = Emojis.status_offline + + ext = ext.rsplit(".", 1)[1] + lines.append(f"{status} {ext}") + + log.debug(f"{ctx.author} requested a list of all cogs. Returning a paginated list.") + await LinePaginator.paginate(lines, ctx, embed, max_size=300, empty=False) + + def batch_manage(self, action: Action, *extensions: str) -> str: + """ + Apply an action to multiple extensions and return a message with the results. + + If only one extension is given, it is deferred to `manage()`. + """ + if len(extensions) == 1: + msg, _ = self.manage(action, extensions[0]) + return msg + + verb = action.name.lower() + failures = {} + + for extension in extensions: + _, error = self.manage(action, extension) + if error: + failures[extension] = error + + emoji = ":x:" if failures else ":ok_hand:" + msg = f"{emoji} {len(extensions) - len(failures)} / {len(extensions)} extensions {verb}ed." + + if failures: + failures = "\n".join(f"{ext}\n {err}" for ext, err in failures.items()) + msg += f"\nFailures:```{failures}```" + + log.debug(f"Batch {verb}ed extensions.") + + return msg + + def manage(self, action: Action, ext: str) -> t.Tuple[str, t.Optional[str]]: + """Apply an action to an extension and return the status message and any error message.""" + verb = action.name.lower() + error_msg = None + + try: + action.value(self.bot, ext) + except (commands.ExtensionAlreadyLoaded, commands.ExtensionNotLoaded): + if action is Action.RELOAD: + # When reloading, just load the extension if it was not loaded. + return self.manage(Action.LOAD, ext) + + msg = f":x: Extension `{ext}` is already {verb}ed." + log.debug(msg[4:]) + except Exception as e: + if hasattr(e, "original"): + e = e.original + + log.exception(f"Extension '{ext}' failed to {verb}.") + + error_msg = f"{e.__class__.__name__}: {e}" + msg = f":x: Failed to {verb} extension `{ext}`:\n```{error_msg}```" + else: + msg = f":ok_hand: Extension successfully {verb}ed: `{ext}`." + log.debug(msg[10:]) + + return msg, error_msg + + # This cannot be static (must have a __func__ attribute). + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators and core developers to invoke the commands in this cog.""" + return with_role_check(ctx, *MODERATION_ROLES, Roles.core_developers) + + # This cannot be static (must have a __func__ attribute). + async def cog_command_error(self, ctx: Context, error: Exception) -> None: + """Handle BadArgument errors locally to prevent the help command from showing.""" + if isinstance(error, commands.BadArgument): + await ctx.send(str(error)) + error.handled = True + + +def setup(bot: Bot) -> None: + """Load the Extensions cog.""" + bot.add_cog(Extensions(bot)) diff --git a/bot/cogs/utils/jams.py b/bot/cogs/utils/jams.py new file mode 100644 index 000000000..b3102db2f --- /dev/null +++ b/bot/cogs/utils/jams.py @@ -0,0 +1,150 @@ +import logging +import typing as t + +from discord import CategoryChannel, Guild, Member, PermissionOverwrite, Role +from discord.ext import commands +from more_itertools import unique_everseen + +from bot.bot import Bot +from bot.constants import Roles +from bot.decorators import with_role + +log = logging.getLogger(__name__) + +MAX_CHANNELS = 50 +CATEGORY_NAME = "Code Jam" + + +class CodeJams(commands.Cog): + """Manages the code-jam related parts of our server.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @commands.command() + @with_role(Roles.admins) + async def createteam(self, ctx: commands.Context, team_name: str, members: commands.Greedy[Member]) -> None: + """ + Create team channels (voice and text) in the Code Jams category, assign roles, and add overwrites for the team. + + The first user passed will always be the team leader. + """ + # Ignore duplicate members + members = list(unique_everseen(members)) + + # We had a little issue during Code Jam 4 here, the greedy converter did it's job + # and ignored anything which wasn't a valid argument which left us with teams of + # two members or at some times even 1 member. This fixes that by checking that there + # are always 3 members in the members list. + if len(members) < 3: + await ctx.send( + ":no_entry_sign: One of your arguments was invalid\n" + f"There must be a minimum of 3 valid members in your team. Found: {len(members)}" + " members" + ) + return + + team_channel = await self.create_channels(ctx.guild, team_name, members) + await self.add_roles(ctx.guild, members) + + await ctx.send( + f":ok_hand: Team created: {team_channel}\n" + f"**Team Leader:** {members[0].mention}\n" + f"**Team Members:** {' '.join(member.mention for member in members[1:])}" + ) + + async def get_category(self, guild: Guild) -> CategoryChannel: + """ + Return a code jam category. + + If all categories are full or none exist, create a new category. + """ + for category in guild.categories: + # Need 2 available spaces: one for the text channel and one for voice. + if category.name == CATEGORY_NAME and MAX_CHANNELS - len(category.channels) >= 2: + return category + + return await self.create_category(guild) + + @staticmethod + async def create_category(guild: Guild) -> CategoryChannel: + """Create a new code jam category and return it.""" + log.info("Creating a new code jam category.") + + category_overwrites = { + guild.default_role: PermissionOverwrite(read_messages=False), + guild.me: PermissionOverwrite(read_messages=True) + } + + return await guild.create_category_channel( + CATEGORY_NAME, + overwrites=category_overwrites, + reason="It's code jam time!" + ) + + @staticmethod + def get_overwrites(members: t.List[Member], guild: Guild) -> t.Dict[t.Union[Member, Role], PermissionOverwrite]: + """Get code jam team channels permission overwrites.""" + # First member is always the team leader + team_channel_overwrites = { + members[0]: PermissionOverwrite( + manage_messages=True, + read_messages=True, + manage_webhooks=True, + connect=True + ), + guild.default_role: PermissionOverwrite(read_messages=False, connect=False), + guild.get_role(Roles.verified): PermissionOverwrite( + read_messages=False, + connect=False + ) + } + + # Rest of members should just have read_messages + for member in members[1:]: + team_channel_overwrites[member] = PermissionOverwrite( + read_messages=True, + connect=True + ) + + return team_channel_overwrites + + async def create_channels(self, guild: Guild, team_name: str, members: t.List[Member]) -> str: + """Create team text and voice channels. Return the mention for the text channel.""" + # Get permission overwrites and category + team_channel_overwrites = self.get_overwrites(members, guild) + code_jam_category = await self.get_category(guild) + + # Create a text channel for the team + team_channel = await guild.create_text_channel( + team_name, + overwrites=team_channel_overwrites, + category=code_jam_category + ) + + # Create a voice channel for the team + team_voice_name = " ".join(team_name.split("-")).title() + + await guild.create_voice_channel( + team_voice_name, + overwrites=team_channel_overwrites, + category=code_jam_category + ) + + return team_channel.mention + + @staticmethod + async def add_roles(guild: Guild, members: t.List[Member]) -> None: + """Assign team leader and jammer roles.""" + # Assign team leader role + await members[0].add_roles(guild.get_role(Roles.team_leaders)) + + # Assign rest of roles + jammer_role = guild.get_role(Roles.jammers) + for member in members: + await member.add_roles(jammer_role) + + +def setup(bot: Bot) -> None: + """Load the CodeJams cog.""" + bot.add_cog(CodeJams(bot)) diff --git a/bot/cogs/utils/reminders.py b/bot/cogs/utils/reminders.py new file mode 100644 index 000000000..670493bcf --- /dev/null +++ b/bot/cogs/utils/reminders.py @@ -0,0 +1,427 @@ +import asyncio +import logging +import random +import textwrap +import typing as t +from datetime import datetime, timedelta +from operator import itemgetter + +import discord +from dateutil.parser import isoparse +from dateutil.relativedelta import relativedelta +from discord.ext.commands import Cog, Context, Greedy, group + +from bot.bot import Bot +from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, STAFF_ROLES +from bot.converters import Duration +from bot.pagination import LinePaginator +from bot.utils.checks import without_role_check +from bot.utils.messages import send_denial +from bot.utils.scheduling import Scheduler +from bot.utils.time import humanize_delta + +log = logging.getLogger(__name__) + +WHITELISTED_CHANNELS = Guild.reminder_whitelist +MAXIMUM_REMINDERS = 5 + +Mentionable = t.Union[discord.Member, discord.Role] + + +class Reminders(Cog): + """Provide in-channel reminder functionality.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.scheduler = Scheduler(self.__class__.__name__) + + self.bot.loop.create_task(self.reschedule_reminders()) + + def cog_unload(self) -> None: + """Cancel scheduled tasks.""" + self.scheduler.cancel_all() + + async def reschedule_reminders(self) -> None: + """Get all current reminders from the API and reschedule them.""" + await self.bot.wait_until_guild_available() + response = await self.bot.api_client.get( + 'bot/reminders', + params={'active': 'true'} + ) + + now = datetime.utcnow() + + for reminder in response: + is_valid, *_ = self.ensure_valid_reminder(reminder, cancel_task=False) + if not is_valid: + continue + + remind_at = isoparse(reminder['expiration']).replace(tzinfo=None) + + # If the reminder is already overdue ... + if remind_at < now: + late = relativedelta(now, remind_at) + await self.send_reminder(reminder, late) + else: + self.schedule_reminder(reminder) + + def ensure_valid_reminder( + self, + reminder: dict, + cancel_task: bool = True + ) -> t.Tuple[bool, discord.User, discord.TextChannel]: + """Ensure reminder author and channel can be fetched otherwise delete the reminder.""" + user = self.bot.get_user(reminder['author']) + channel = self.bot.get_channel(reminder['channel_id']) + is_valid = True + if not user or not channel: + is_valid = False + log.info( + f"Reminder {reminder['id']} invalid: " + f"User {reminder['author']}={user}, Channel {reminder['channel_id']}={channel}." + ) + asyncio.create_task(self._delete_reminder(reminder['id'], cancel_task)) + + return is_valid, user, channel + + @staticmethod + async def _send_confirmation( + ctx: Context, + on_success: str, + reminder_id: str, + 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 + + 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')}" + + embed.set_footer(text=footer_str) + + await ctx.send(embed=embed) + + @staticmethod + async def _check_mentions(ctx: Context, mentions: t.Iterable[Mentionable]) -> t.Tuple[bool, str]: + """ + Returns whether or not the list of mentions is allowed. + + Conditions: + - Role reminders are Mods+ + - Reminders for other users are Helpers+ + + If mentions aren't allowed, also return the type of mention(s) disallowed. + """ + if without_role_check(ctx, *STAFF_ROLES): + return False, "members/roles" + elif without_role_check(ctx, *MODERATION_ROLES): + return all(isinstance(mention, discord.Member) for mention in mentions), "roles" + else: + return True, "" + + @staticmethod + async def validate_mentions(ctx: Context, mentions: t.Iterable[Mentionable]) -> bool: + """ + Filter mentions to see if the user can mention, and sends a denial if not allowed. + + Returns whether or not the validation is successful. + """ + mentions_allowed, disallowed_mentions = await Reminders._check_mentions(ctx, mentions) + + if not mentions or mentions_allowed: + return True + else: + await send_denial(ctx, f"You can't mention other {disallowed_mentions} in your reminder!") + return False + + def get_mentionables(self, mention_ids: t.List[int]) -> t.Iterator[Mentionable]: + """Converts Role and Member ids to their corresponding objects if possible.""" + guild = self.bot.get_guild(Guild.id) + for mention_id in mention_ids: + if (mentionable := (guild.get_member(mention_id) or guild.get_role(mention_id))): + yield mentionable + + def schedule_reminder(self, reminder: dict) -> None: + """A coroutine which sends the reminder once the time is reached, and cancels the running task.""" + reminder_id = reminder["id"] + reminder_datetime = isoparse(reminder['expiration']).replace(tzinfo=None) + + async def _remind() -> None: + await self.send_reminder(reminder) + + log.debug(f"Deleting reminder {reminder_id} (the user has been reminded).") + await self._delete_reminder(reminder_id) + + self.scheduler.schedule_at(reminder_datetime, reminder_id, _remind()) + + async def _delete_reminder(self, reminder_id: str, cancel_task: bool = True) -> None: + """Delete a reminder from the database, given its ID, and cancel the running task.""" + await self.bot.api_client.delete('bot/reminders/' + str(reminder_id)) + + if cancel_task: + # Now we can remove it from the schedule list + self.scheduler.cancel(reminder_id) + + async def _edit_reminder(self, reminder_id: int, payload: dict) -> dict: + """ + Edits a reminder in the database given the ID and payload. + + Returns the edited reminder. + """ + # Send the request to update the reminder in the database + reminder = await self.bot.api_client.patch( + 'bot/reminders/' + str(reminder_id), + json=payload + ) + return reminder + + async def _reschedule_reminder(self, reminder: dict) -> None: + """Reschedule a reminder object.""" + log.trace(f"Cancelling old task #{reminder['id']}") + self.scheduler.cancel(reminder["id"]) + + log.trace(f"Scheduling new task #{reminder['id']}") + self.schedule_reminder(reminder) + + async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: + """Send the reminder.""" + is_valid, user, channel = self.ensure_valid_reminder(reminder) + if not is_valid: + return + + embed = discord.Embed() + embed.colour = discord.Colour.blurple() + embed.set_author( + icon_url=Icons.remind_blurple, + name="It has arrived!" + ) + + embed.description = f"Here's your reminder: `{reminder['content']}`." + + if reminder.get("jump_url"): # keep backward compatibility + embed.description += f"\n[Jump back to when you created the reminder]({reminder['jump_url']})" + + if late: + embed.colour = discord.Colour.red() + embed.set_author( + icon_url=Icons.remind_red, + name=f"Sorry it arrived {humanize_delta(late, max_units=2)} late!" + ) + + additional_mentions = ' '.join( + mentionable.mention for mentionable in self.get_mentionables(reminder["mentions"]) + ) + + await channel.send( + content=f"{user.mention} {additional_mentions}", + embed=embed + ) + await self._delete_reminder(reminder["id"]) + + @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True) + async def remind_group( + self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str + ) -> None: + """Commands for managing your reminders.""" + await ctx.invoke(self.new_reminder, mentions=mentions, expiration=expiration, content=content) + + @remind_group.command(name="new", aliases=("add", "create")) + async def new_reminder( + self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str + ) -> None: + """ + Set yourself a simple reminder. + + Expiration is parsed per: http://strftime.org/ + """ + # If the user is not staff, we need to verify whether or not to make a reminder at all. + if without_role_check(ctx, *STAFF_ROLES): + + # If they don't have permission to set a reminder in this channel + if ctx.channel.id not in WHITELISTED_CHANNELS: + await send_denial(ctx, "Sorry, you can't do that here!") + return + + # Get their current active reminders + active_reminders = await self.bot.api_client.get( + 'bot/reminders', + params={ + 'author__id': str(ctx.author.id) + } + ) + + # Let's limit this, so we don't get 10 000 + # reminders from kip or something like that :P + if len(active_reminders) > MAXIMUM_REMINDERS: + await send_denial(ctx, "You have too many active reminders!") + return + + # Remove duplicate mentions + mentions = set(mentions) + mentions.discard(ctx.author) + + # Filter mentions to see if the user can mention members/roles + if not await self.validate_mentions(ctx, mentions): + return + + mention_ids = [mention.id for mention in mentions] + + # Now we can attempt to actually set the reminder. + reminder = await self.bot.api_client.post( + 'bot/reminders', + json={ + 'author': ctx.author.id, + 'channel_id': ctx.message.channel.id, + 'jump_url': ctx.message.jump_url, + 'content': content, + 'expiration': expiration.isoformat(), + 'mentions': mention_ids, + } + ) + + now = datetime.utcnow() - timedelta(seconds=1) + humanized_delta = humanize_delta(relativedelta(expiration, now)) + mention_string = ( + f"Your reminder will arrive in {humanized_delta} " + f"and will mention {len(mentions)} other(s)!" + ) + + # Confirm to the user that it worked. + await self._send_confirmation( + ctx, + on_success=mention_string, + reminder_id=reminder["id"], + delivery_dt=expiration, + ) + + self.schedule_reminder(reminder) + + @remind_group.command(name="list") + async def list_reminders(self, ctx: Context) -> None: + """View a paginated embed of all reminders for your user.""" + # Get all the user's reminders from the database. + data = await self.bot.api_client.get( + 'bot/reminders', + params={'author__id': str(ctx.author.id)} + ) + + now = datetime.utcnow() + + # Make a list of tuples so it can be sorted by time. + reminders = sorted( + ( + (rem['content'], rem['expiration'], rem['id'], rem['mentions']) + for rem in data + ), + key=itemgetter(1) + ) + + lines = [] + + for content, remind_at, id_, mentions in reminders: + # Parse and humanize the time, make it pretty :D + remind_datetime = isoparse(remind_at).replace(tzinfo=None) + time = humanize_delta(relativedelta(remind_datetime, now)) + + mentions = ", ".join( + # Both Role and User objects have the `name` attribute + mention.name for mention in self.get_mentionables(mentions) + ) + mention_string = f"\n**Mentions:** {mentions}" if mentions else "" + + text = textwrap.dedent(f""" + **Reminder #{id_}:** *expires in {time}* (ID: {id_}){mention_string} + {content} + """).strip() + + lines.append(text) + + embed = discord.Embed() + embed.colour = discord.Colour.blurple() + embed.title = f"Reminders for {ctx.author}" + + # Remind the user that they have no reminders :^) + if not lines: + embed.description = "No active reminders could be found." + await ctx.send(embed=embed) + return + + # Construct the embed and paginate it. + embed.colour = discord.Colour.blurple() + + await LinePaginator.paginate( + lines, + ctx, embed, + max_lines=3, + empty=True + ) + + @remind_group.group(name="edit", aliases=("change", "modify"), invoke_without_command=True) + async def edit_reminder_group(self, ctx: Context) -> None: + """Commands for modifying your current reminders.""" + await ctx.send_help(ctx.command) + + @edit_reminder_group.command(name="duration", aliases=("time",)) + async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: Duration) -> None: + """ + Edit one of your reminder's expiration. + + Expiration is parsed per: http://strftime.org/ + """ + await self.edit_reminder(ctx, id_, {'expiration': expiration.isoformat()}) + + @edit_reminder_group.command(name="content", aliases=("reason",)) + async def edit_reminder_content(self, ctx: Context, id_: int, *, content: str) -> None: + """Edit one of your reminder's content.""" + await self.edit_reminder(ctx, id_, {"content": content}) + + @edit_reminder_group.command(name="mentions", aliases=("pings",)) + async def edit_reminder_mentions(self, ctx: Context, id_: int, mentions: Greedy[Mentionable]) -> None: + """Edit one of your reminder's mentions.""" + # Remove duplicate mentions + mentions = set(mentions) + mentions.discard(ctx.author) + + # Filter mentions to see if the user can mention members/roles + if not await self.validate_mentions(ctx, mentions): + return + + mention_ids = [mention.id for mention in mentions] + await self.edit_reminder(ctx, id_, {"mentions": mention_ids}) + + async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: + """Edits a reminder with the given payload, then sends a confirmation message.""" + reminder = await self._edit_reminder(id_, payload) + + # Parse the reminder expiration back into a datetime + expiration = isoparse(reminder["expiration"]).replace(tzinfo=None) + + # Send a confirmation message to the channel + await self._send_confirmation( + ctx, + on_success="That reminder has been edited successfully!", + reminder_id=id_, + delivery_dt=expiration, + ) + await self._reschedule_reminder(reminder) + + @remind_group.command("delete", aliases=("remove", "cancel")) + async def delete_reminder(self, ctx: Context, id_: int) -> None: + """Delete one of your active reminders.""" + await self._delete_reminder(id_) + await self._send_confirmation( + ctx, + on_success="That reminder has been deleted successfully!", + reminder_id=id_, + delivery_dt=None, + ) + + +def setup(bot: Bot) -> None: + """Load the Reminders cog.""" + bot.add_cog(Reminders(bot)) diff --git a/bot/cogs/utils/snekbox.py b/bot/cogs/utils/snekbox.py new file mode 100644 index 000000000..52c8b6f88 --- /dev/null +++ b/bot/cogs/utils/snekbox.py @@ -0,0 +1,349 @@ +import asyncio +import contextlib +import datetime +import logging +import re +import textwrap +from functools import partial +from signal import Signals +from typing import Optional, Tuple + +from discord import HTTPException, Message, NotFound, Reaction, User +from discord.ext.commands import Cog, Context, command, guild_only + +from bot.bot import Bot +from bot.constants import Categories, Channels, Roles, URLs +from bot.decorators import in_whitelist +from bot.utils.messages import wait_for_deletion + +log = logging.getLogger(__name__) + +ESCAPE_REGEX = re.compile("[`\u202E\u200B]{3,}") +FORMATTED_CODE_REGEX = re.compile( + r"^\s*" # any leading whitespace from the beginning of the string + r"(?P(?P```)|``?)" # code delimiter: 1-3 backticks; (?P=block) only matches if it's a block + r"(?(block)(?:(?P[a-z]+)\n)?)" # if we're in a block, match optional language (only letters plus newline) + r"(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code + r"(?P.*?)" # extract all code inside the markup + r"\s*" # any more whitespace before the end of the code markup + r"(?P=delim)" # match the exact same delimiter from the start again + r"\s*$", # any trailing whitespace until the end of the string + re.DOTALL | re.IGNORECASE # "." also matches newlines, case insensitive +) +RAW_CODE_REGEX = re.compile( + r"^(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code + r"(?P.*?)" # extract all the rest as code + r"\s*$", # any trailing whitespace until the end of the string + re.DOTALL # "." also matches newlines +) + +MAX_PASTE_LEN = 1000 + +# `!eval` command whitelists +EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric) +EVAL_CATEGORIES = (Categories.help_available, Categories.help_in_use) +EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners) + +SIGKILL = 9 + +REEVAL_EMOJI = '\U0001f501' # :repeat: +REEVAL_TIMEOUT = 30 + + +class Snekbox(Cog): + """Safe evaluation of Python code using Snekbox.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.jobs = {} + + async def post_eval(self, code: str) -> dict: + """Send a POST request to the Snekbox API to evaluate code and return the results.""" + url = URLs.snekbox_eval_api + data = {"input": code} + async with self.bot.http_session.post(url, json=data, raise_for_status=True) as resp: + return await resp.json() + + async def upload_output(self, output: str) -> Optional[str]: + """Upload the eval output to a paste service and return a URL to it if successful.""" + log.trace("Uploading full output to paste service...") + + if len(output) > MAX_PASTE_LEN: + log.info("Full output is too long to upload") + return "too long to upload" + + url = URLs.paste_service.format(key="documents") + try: + async with self.bot.http_session.post(url, data=output, raise_for_status=True) as resp: + data = await resp.json() + + if "key" in data: + return URLs.paste_service.format(key=data["key"]) + except Exception: + # 400 (Bad Request) means there are too many characters + log.exception("Failed to upload full output to paste service!") + + @staticmethod + def prepare_input(code: str) -> str: + """Extract code from the Markdown, format it, and insert it into the code template.""" + match = FORMATTED_CODE_REGEX.fullmatch(code) + if match: + code, block, lang, delim = match.group("code", "block", "lang", "delim") + code = textwrap.dedent(code) + if block: + info = (f"'{lang}' highlighted" if lang else "plain") + " code block" + else: + info = f"{delim}-enclosed inline code" + log.trace(f"Extracted {info} for evaluation:\n{code}") + else: + code = textwrap.dedent(RAW_CODE_REGEX.fullmatch(code).group("code")) + log.trace( + f"Eval message contains unformatted or badly formatted code, " + f"stripping whitespace only:\n{code}" + ) + + return code + + @staticmethod + def get_results_message(results: dict) -> Tuple[str, str]: + """Return a user-friendly message and error corresponding to the process's return code.""" + stdout, returncode = results["stdout"], results["returncode"] + msg = f"Your eval job has completed with return code {returncode}" + error = "" + + if returncode is None: + msg = "Your eval job has failed" + error = stdout.strip() + elif returncode == 128 + SIGKILL: + msg = "Your eval job timed out or ran out of memory" + elif returncode == 255: + msg = "Your eval job has failed" + error = "A fatal NsJail error occurred" + else: + # Try to append signal's name if one exists + try: + name = Signals(returncode - 128).name + msg = f"{msg} ({name})" + except ValueError: + pass + + return msg, error + + @staticmethod + def get_status_emoji(results: dict) -> str: + """Return an emoji corresponding to the status code or lack of output in result.""" + if not results["stdout"].strip(): # No output + return ":warning:" + elif results["returncode"] == 0: # No error + return ":white_check_mark:" + else: # Exception + return ":x:" + + async def format_output(self, output: str) -> Tuple[str, Optional[str]]: + """ + Format the output and return a tuple of the formatted output and a URL to the full output. + + Prepend each line with a line number. Truncate if there are over 10 lines or 1000 characters + and upload the full output to a paste service. + """ + log.trace("Formatting output...") + + output = output.rstrip("\n") + original_output = output # To be uploaded to a pasting service if needed + paste_link = None + + if "<@" in output: + output = output.replace("<@", "<@\u200B") # Zero-width space + + if " 0: + output = [f"{i:03d} | {line}" for i, line in enumerate(output.split('\n'), 1)] + output = output[:11] # Limiting to only 11 lines + output = "\n".join(output) + + if lines > 10: + truncated = True + if len(output) >= 1000: + output = f"{output[:1000]}\n... (truncated - too long, too many lines)" + else: + output = f"{output}\n... (truncated - too many lines)" + elif len(output) >= 1000: + truncated = True + output = f"{output[:1000]}\n... (truncated - too long)" + + if truncated: + paste_link = await self.upload_output(original_output) + + output = output or "[No output]" + + return output, paste_link + + async def send_eval(self, ctx: Context, code: str) -> Message: + """ + Evaluate code, format it, and send the output to the corresponding channel. + + Return the bot response. + """ + async with ctx.typing(): + results = await self.post_eval(code) + msg, error = self.get_results_message(results) + + if error: + output, paste_link = error, None + else: + output, paste_link = await self.format_output(results["stdout"]) + + icon = self.get_status_emoji(results) + msg = f"{ctx.author.mention} {icon} {msg}.\n\n```\n{output}\n```" + if paste_link: + msg = f"{msg}\nFull output: {paste_link}" + + # Collect stats of eval fails + successes + if icon == ":x:": + self.bot.stats.incr("snekbox.python.fail") + else: + self.bot.stats.incr("snekbox.python.success") + + filter_cog = self.bot.get_cog("Filtering") + filter_triggered = False + if filter_cog: + filter_triggered = await filter_cog.filter_eval(msg, ctx.message) + if filter_triggered: + response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.") + else: + response = await ctx.send(msg) + self.bot.loop.create_task( + wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot) + ) + + log.info(f"{ctx.author}'s job had a return code of {results['returncode']}") + return response + + async def continue_eval(self, ctx: Context, response: Message) -> Optional[str]: + """ + Check if the eval session should continue. + + Return the new code to evaluate or None if the eval session should be terminated. + """ + _predicate_eval_message_edit = partial(predicate_eval_message_edit, ctx) + _predicate_emoji_reaction = partial(predicate_eval_emoji_reaction, ctx) + + with contextlib.suppress(NotFound): + try: + _, new_message = await self.bot.wait_for( + 'message_edit', + check=_predicate_eval_message_edit, + timeout=REEVAL_TIMEOUT + ) + await ctx.message.add_reaction(REEVAL_EMOJI) + await self.bot.wait_for( + 'reaction_add', + check=_predicate_emoji_reaction, + timeout=10 + ) + + code = await self.get_code(new_message) + await ctx.message.clear_reactions() + with contextlib.suppress(HTTPException): + await response.delete() + + except asyncio.TimeoutError: + await ctx.message.clear_reactions() + return None + + return code + + async def get_code(self, message: Message) -> Optional[str]: + """ + Return the code from `message` to be evaluated. + + If the message is an invocation of the eval command, return the first argument or None if it + doesn't exist. Otherwise, return the full content of the message. + """ + log.trace(f"Getting context for message {message.id}.") + new_ctx = await self.bot.get_context(message) + + if new_ctx.command is self.eval_command: + log.trace(f"Message {message.id} invokes eval command.") + split = message.content.split(maxsplit=1) + code = split[1] if len(split) > 1 else None + else: + log.trace(f"Message {message.id} does not invoke eval command.") + code = message.content + + return code + + @command(name="eval", aliases=("e",)) + @guild_only() + @in_whitelist(channels=EVAL_CHANNELS, categories=EVAL_CATEGORIES, roles=EVAL_ROLES) + async def eval_command(self, ctx: Context, *, code: str = None) -> None: + """ + Run Python code and get the results. + + This command supports multiple lines of code, including code wrapped inside a formatted code + block. Code can be re-evaluated by editing the original message within 10 seconds and + clicking the reaction that subsequently appears. + + We've done our best to make this sandboxed, but do let us know if you manage to find an + issue with it! + """ + if ctx.author.id in self.jobs: + await ctx.send( + f"{ctx.author.mention} You've already got a job running - " + "please wait for it to finish!" + ) + return + + if not code: # None or empty string + await ctx.send_help(ctx.command) + return + + if Roles.helpers in (role.id for role in ctx.author.roles): + self.bot.stats.incr("snekbox_usages.roles.helpers") + else: + self.bot.stats.incr("snekbox_usages.roles.developers") + + if ctx.channel.category_id == Categories.help_in_use: + self.bot.stats.incr("snekbox_usages.channels.help") + elif ctx.channel.id == Channels.bot_commands: + self.bot.stats.incr("snekbox_usages.channels.bot_commands") + else: + self.bot.stats.incr("snekbox_usages.channels.topical") + + log.info(f"Received code from {ctx.author} for evaluation:\n{code}") + + while True: + self.jobs[ctx.author.id] = datetime.datetime.now() + code = self.prepare_input(code) + try: + response = await self.send_eval(ctx, code) + finally: + del self.jobs[ctx.author.id] + + code = await self.continue_eval(ctx, response) + if not code: + break + log.info(f"Re-evaluating code from message {ctx.message.id}:\n{code}") + + +def predicate_eval_message_edit(ctx: Context, old_msg: Message, new_msg: Message) -> bool: + """Return True if the edited message is the context message and the content was indeed modified.""" + return new_msg.id == ctx.message.id and old_msg.content != new_msg.content + + +def predicate_eval_emoji_reaction(ctx: Context, reaction: Reaction, user: User) -> bool: + """Return True if the reaction REEVAL_EMOJI was added by the context message author on this message.""" + return reaction.message.id == ctx.message.id and user.id == ctx.author.id and str(reaction) == REEVAL_EMOJI + + +def setup(bot: Bot) -> None: + """Load the Snekbox cog.""" + bot.add_cog(Snekbox(bot)) diff --git a/bot/cogs/utils/utils.py b/bot/cogs/utils/utils.py new file mode 100644 index 000000000..d96abbd5a --- /dev/null +++ b/bot/cogs/utils/utils.py @@ -0,0 +1,265 @@ +import difflib +import logging +import re +import unicodedata +from email.parser import HeaderParser +from io import StringIO +from typing import Tuple, Union + +from discord import Colour, Embed, utils +from discord.ext.commands import BadArgument, Cog, Context, clean_content, command + +from bot.bot import Bot +from bot.constants import Channels, MODERATION_ROLES, STAFF_ROLES +from bot.decorators import in_whitelist, with_role +from bot.pagination import LinePaginator +from bot.utils import messages + +log = logging.getLogger(__name__) + +ZEN_OF_PYTHON = """\ +Beautiful is better than ugly. +Explicit is better than implicit. +Simple is better than complex. +Complex is better than complicated. +Flat is better than nested. +Sparse is better than dense. +Readability counts. +Special cases aren't special enough to break the rules. +Although practicality beats purity. +Errors should never pass silently. +Unless explicitly silenced. +In the face of ambiguity, refuse the temptation to guess. +There should be one-- and preferably only one --obvious way to do it. +Although that way may not be obvious at first unless you're Dutch. +Now is better than never. +Although never is often better than *right* now. +If the implementation is hard to explain, it's a bad idea. +If the implementation is easy to explain, it may be a good idea. +Namespaces are one honking great idea -- let's do more of those! +""" + +ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png" + + +class Utils(Cog): + """A selection of utilities which don't have a clear category.""" + + def __init__(self, bot: Bot): + self.bot = bot + + self.base_pep_url = "http://www.python.org/dev/peps/pep-" + self.base_github_pep_url = "https://raw.githubusercontent.com/python/peps/master/pep-" + + @command(name='pep', aliases=('get_pep', 'p')) + async def pep_command(self, ctx: Context, pep_number: str) -> None: + """Fetches information about a PEP and sends it to the channel.""" + if pep_number.isdigit(): + pep_number = int(pep_number) + else: + await ctx.send_help(ctx.command) + return + + # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs. + if pep_number == 0: + return await self.send_pep_zero(ctx) + + possible_extensions = ['.txt', '.rst'] + found_pep = False + for extension in possible_extensions: + # Attempt to fetch the PEP + pep_url = f"{self.base_github_pep_url}{pep_number:04}{extension}" + log.trace(f"Requesting PEP {pep_number} with {pep_url}") + response = await self.bot.http_session.get(pep_url) + + if response.status == 200: + log.trace("PEP found") + found_pep = True + + pep_content = await response.text() + + # Taken from https://github.com/python/peps/blob/master/pep0/pep.py#L179 + pep_header = HeaderParser().parse(StringIO(pep_content)) + + # Assemble the embed + pep_embed = Embed( + title=f"**PEP {pep_number} - {pep_header['Title']}**", + description=f"[Link]({self.base_pep_url}{pep_number:04})", + ) + + pep_embed.set_thumbnail(url=ICON_URL) + + # Add the interesting information + fields_to_check = ("Status", "Python-Version", "Created", "Type") + for field in fields_to_check: + # Check for a PEP metadata field that is present but has an empty value + # embed field values can't contain an empty string + if pep_header.get(field, ""): + pep_embed.add_field(name=field, value=pep_header[field]) + + elif response.status != 404: + # any response except 200 and 404 is expected + found_pep = True # actually not, but it's easier to display this way + log.trace(f"The user requested PEP {pep_number}, but the response had an unexpected status code: " + f"{response.status}.\n{response.text}") + + error_message = "Unexpected HTTP error during PEP search. Please let us know." + pep_embed = Embed(title="Unexpected error", description=error_message) + pep_embed.colour = Colour.red() + break + + if not found_pep: + log.trace("PEP was not found") + not_found = f"PEP {pep_number} does not exist." + pep_embed = Embed(title="PEP not found", description=not_found) + pep_embed.colour = Colour.red() + + await ctx.message.channel.send(embed=pep_embed) + + @command() + @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES) + async def charinfo(self, ctx: Context, *, characters: str) -> None: + """Shows you information on up to 50 unicode characters.""" + match = re.match(r"<(a?):(\w+):(\d+)>", characters) + if match: + return await messages.send_denial( + ctx, + "**Non-Character Detected**\n" + "Only unicode characters can be processed, but a custom Discord emoji " + "was found. Please remove it and try again." + ) + + if len(characters) > 50: + return await messages.send_denial(ctx, f"Too many characters ({len(characters)}/50)") + + def get_info(char: str) -> Tuple[str, str]: + digit = f"{ord(char):x}" + if len(digit) <= 4: + u_code = f"\\u{digit:>04}" + else: + u_code = f"\\U{digit:>08}" + url = f"https://www.compart.com/en/unicode/U+{digit:>04}" + name = f"[{unicodedata.name(char, '')}]({url})" + info = f"`{u_code.ljust(10)}`: {name} - {utils.escape_markdown(char)}" + return info, u_code + + char_list, raw_list = zip(*(get_info(c) for c in characters)) + embed = Embed().set_author(name="Character Info") + + if len(characters) > 1: + # Maximum length possible is 502 out of 1024, so there's no need to truncate. + embed.add_field(name='Full Raw Text', value=f"`{''.join(raw_list)}`", inline=False) + + await LinePaginator.paginate(char_list, ctx, embed, max_lines=10, max_size=2000, empty=False) + + @command() + async def zen(self, ctx: Context, *, search_value: Union[int, str, None] = None) -> None: + """ + Show the Zen of Python. + + Without any arguments, the full Zen will be produced. + If an integer is provided, the line with that index will be produced. + If a string is provided, the line which matches best will be produced. + """ + embed = Embed( + colour=Colour.blurple(), + title="The Zen of Python", + description=ZEN_OF_PYTHON + ) + + if search_value is None: + embed.title += ", by Tim Peters" + await ctx.send(embed=embed) + return + + zen_lines = ZEN_OF_PYTHON.splitlines() + + # handle if it's an index int + if isinstance(search_value, int): + upper_bound = len(zen_lines) - 1 + lower_bound = -1 * upper_bound + if not (lower_bound <= search_value <= upper_bound): + raise BadArgument(f"Please provide an index between {lower_bound} and {upper_bound}.") + + embed.title += f" (line {search_value % len(zen_lines)}):" + embed.description = zen_lines[search_value] + await ctx.send(embed=embed) + return + + # Try to handle first exact word due difflib.SequenceMatched may use some other similar word instead + # exact word. + for i, line in enumerate(zen_lines): + for word in line.split(): + if word.lower() == search_value.lower(): + embed.title += f" (line {i}):" + embed.description = line + await ctx.send(embed=embed) + return + + # handle if it's a search string and not exact word + matcher = difflib.SequenceMatcher(None, search_value.lower()) + + best_match = "" + match_index = 0 + best_ratio = 0 + + for index, line in enumerate(zen_lines): + matcher.set_seq2(line.lower()) + + # the match ratio needs to be adjusted because, naturally, + # longer lines will have worse ratios than shorter lines when + # fuzzy searching for keywords. this seems to work okay. + adjusted_ratio = (len(line) - 5) ** 0.5 * matcher.ratio() + + if adjusted_ratio > best_ratio: + best_ratio = adjusted_ratio + best_match = line + match_index = index + + if not best_match: + raise BadArgument("I didn't get a match! Please try again with a different search term.") + + embed.title += f" (line {match_index}):" + embed.description = best_match + await ctx.send(embed=embed) + + @command(aliases=("poll",)) + @with_role(*MODERATION_ROLES) + async def vote(self, ctx: Context, title: clean_content(fix_channel_mentions=True), *options: str) -> None: + """ + Build a quick voting poll with matching reactions with the provided options. + + A maximum of 20 options can be provided, as Discord supports a max of 20 + reactions on a single message. + """ + if len(title) > 256: + raise BadArgument("The title cannot be longer than 256 characters.") + if len(options) < 2: + raise BadArgument("Please provide at least 2 options.") + if len(options) > 20: + raise BadArgument("I can only handle 20 options!") + + codepoint_start = 127462 # represents "regional_indicator_a" unicode value + options = {chr(i): f"{chr(i)} - {v}" for i, v in enumerate(options, start=codepoint_start)} + embed = Embed(title=title, description="\n".join(options.values())) + message = await ctx.send(embed=embed) + for reaction in options: + await message.add_reaction(reaction) + + async def send_pep_zero(self, ctx: Context) -> None: + """Send information about PEP 0.""" + pep_embed = Embed( + title="**PEP 0 - Index of Python Enhancement Proposals (PEPs)**", + description="[Link](https://www.python.org/dev/peps/)" + ) + pep_embed.set_thumbnail(url=ICON_URL) + pep_embed.add_field(name="Status", value="Active") + pep_embed.add_field(name="Created", value="13-Jul-2000") + pep_embed.add_field(name="Type", value="Informational") + + await ctx.send(embed=pep_embed) + + +def setup(bot: Bot) -> None: + """Load the Utils cog.""" + bot.add_cog(Utils(bot)) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py deleted file mode 100644 index ae156cf70..000000000 --- a/bot/cogs/verification.py +++ /dev/null @@ -1,191 +0,0 @@ -import logging -from contextlib import suppress - -from discord import Colour, Forbidden, Message, NotFound, Object -from discord.ext.commands import Cog, Context, command - -from bot import constants -from bot.bot import Bot -from bot.cogs.moderation import ModLog -from bot.decorators import in_whitelist, without_role -from bot.utils.checks import InWhitelistCheckFailure, without_role_check - -log = logging.getLogger(__name__) - -WELCOME_MESSAGE = f""" -Hello! Welcome to the server, and thanks for verifying yourself! - -For your records, these are the documents you accepted: - -`1)` Our rules, here: -`2)` Our privacy policy, here: - you can find information on how to have \ -your information removed here as well. - -Feel free to review them at any point! - -Additionally, if you'd like to receive notifications for the announcements \ -we post in <#{constants.Channels.announcements}> -from time to time, you can send `!subscribe` to <#{constants.Channels.bot_commands}> at any time \ -to assign yourself the **Announcements** role. We'll mention this role every time we make an announcement. - -If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to \ -<#{constants.Channels.bot_commands}>. -""" - -BOT_MESSAGE_DELETE_DELAY = 10 - - -class Verification(Cog): - """User verification and role self-management.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - @Cog.listener() - async def on_message(self, message: Message) -> None: - """Check new message event for messages to the checkpoint channel & process.""" - if message.channel.id != constants.Channels.verification: - return # Only listen for #checkpoint messages - - if message.author.bot: - # They're a bot, delete their message after the delay. - await message.delete(delay=BOT_MESSAGE_DELETE_DELAY) - return - - # if a user mentions a role or guild member - # alert the mods in mod-alerts channel - if message.mentions or message.role_mentions: - log.debug( - f"{message.author} mentioned one or more users " - f"and/or roles in {message.channel.name}" - ) - - embed_text = ( - f"{message.author.mention} sent a message in " - f"{message.channel.mention} that contained user and/or role mentions." - f"\n\n**Original message:**\n>>> {message.content}" - ) - - # Send pretty mod log embed to mod-alerts - await self.mod_log.send_log_message( - icon_url=constants.Icons.filtering, - colour=Colour(constants.Colours.soft_red), - title=f"User/Role mentioned in {message.channel.name}", - text=embed_text, - thumbnail=message.author.avatar_url_as(static_format="png"), - channel_id=constants.Channels.mod_alerts, - ) - - ctx: Context = await self.bot.get_context(message) - if ctx.command is not None and ctx.command.name == "accept": - return - - if any(r.id == constants.Roles.verified for r in ctx.author.roles): - log.info( - f"{ctx.author} posted '{ctx.message.content}' " - "in the verification channel, but is already verified." - ) - return - - log.debug( - f"{ctx.author} posted '{ctx.message.content}' in the verification " - "channel. We are providing instructions how to verify." - ) - await ctx.send( - f"{ctx.author.mention} Please type `!accept` to verify that you accept our rules, " - f"and gain access to the rest of the server.", - delete_after=20 - ) - - log.trace(f"Deleting the message posted by {ctx.author}") - with suppress(NotFound): - await ctx.message.delete() - - @command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True) - @without_role(constants.Roles.verified) - @in_whitelist(channels=(constants.Channels.verification,)) - async def accept_command(self, ctx: Context, *_) -> None: # We don't actually care about the args - """Accept our rules and gain access to the rest of the server.""" - log.debug(f"{ctx.author} called !accept. Assigning the 'Developer' role.") - await ctx.author.add_roles(Object(constants.Roles.verified), reason="Accepted the rules") - try: - await ctx.author.send(WELCOME_MESSAGE) - except Forbidden: - log.info(f"Sending welcome message failed for {ctx.author}.") - finally: - log.trace(f"Deleting accept message by {ctx.author}.") - with suppress(NotFound): - self.mod_log.ignore(constants.Event.message_delete, ctx.message.id) - await ctx.message.delete() - - @command(name='subscribe') - @in_whitelist(channels=(constants.Channels.bot_commands,)) - async def subscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args - """Subscribe to announcement notifications by assigning yourself the role.""" - has_role = False - - for role in ctx.author.roles: - if role.id == constants.Roles.announcements: - has_role = True - break - - if has_role: - await ctx.send(f"{ctx.author.mention} You're already subscribed!") - return - - log.debug(f"{ctx.author} called !subscribe. Assigning the 'Announcements' role.") - await ctx.author.add_roles(Object(constants.Roles.announcements), reason="Subscribed to announcements") - - log.trace(f"Deleting the message posted by {ctx.author}.") - - await ctx.send( - f"{ctx.author.mention} Subscribed to <#{constants.Channels.announcements}> notifications.", - ) - - @command(name='unsubscribe') - @in_whitelist(channels=(constants.Channels.bot_commands,)) - async def unsubscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args - """Unsubscribe from announcement notifications by removing the role from yourself.""" - has_role = False - - for role in ctx.author.roles: - if role.id == constants.Roles.announcements: - has_role = True - break - - if not has_role: - await ctx.send(f"{ctx.author.mention} You're already unsubscribed!") - return - - log.debug(f"{ctx.author} called !unsubscribe. Removing the 'Announcements' role.") - await ctx.author.remove_roles(Object(constants.Roles.announcements), reason="Unsubscribed from announcements") - - log.trace(f"Deleting the message posted by {ctx.author}.") - - await ctx.send( - f"{ctx.author.mention} Unsubscribed from <#{constants.Channels.announcements}> notifications." - ) - - # This cannot be static (must have a __func__ attribute). - async def cog_command_error(self, ctx: Context, error: Exception) -> None: - """Check for & ignore any InWhitelistCheckFailure.""" - if isinstance(error, InWhitelistCheckFailure): - error.handled = True - - @staticmethod - def bot_check(ctx: Context) -> bool: - """Block any command within the verification channel that is not !accept.""" - if ctx.channel.id == constants.Channels.verification and without_role_check(ctx, *constants.MODERATION_ROLES): - return ctx.command.name == "accept" - else: - return True - - -def setup(bot: Bot) -> None: - """Load the Verification cog.""" - bot.add_cog(Verification(bot)) diff --git a/bot/cogs/watchchannels/__init__.py b/bot/cogs/watchchannels/__init__.py deleted file mode 100644 index 69d118df6..000000000 --- a/bot/cogs/watchchannels/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from bot.bot import Bot -from .bigbrother import BigBrother -from .talentpool import TalentPool - - -def setup(bot: Bot) -> None: - """Load the BigBrother and TalentPool cogs.""" - bot.add_cog(BigBrother(bot)) - bot.add_cog(TalentPool(bot)) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py deleted file mode 100644 index 4d27a6333..000000000 --- a/bot/cogs/watchchannels/bigbrother.py +++ /dev/null @@ -1,165 +0,0 @@ -import logging -import textwrap -from collections import ChainMap - -from discord.ext.commands import Cog, Context, group - -from bot.bot import Bot -from bot.cogs.moderation.utils import post_infraction -from bot.constants import Channels, MODERATION_ROLES, Webhooks -from bot.converters import FetchedMember -from bot.decorators import with_role -from .watchchannel import WatchChannel - -log = logging.getLogger(__name__) - - -class BigBrother(WatchChannel, Cog, name="Big Brother"): - """Monitors users by relaying their messages to a watch channel to assist with moderation.""" - - def __init__(self, bot: Bot) -> None: - super().__init__( - bot, - destination=Channels.big_brother_logs, - webhook_id=Webhooks.big_brother, - api_endpoint='bot/infractions', - api_default_params={'active': 'true', 'type': 'watch', 'ordering': '-inserted_at'}, - logger=log - ) - - @group(name='bigbrother', aliases=('bb',), invoke_without_command=True) - @with_role(*MODERATION_ROLES) - async def bigbrother_group(self, ctx: Context) -> None: - """Monitors users by relaying their messages to the Big Brother watch channel.""" - await ctx.send_help(ctx.command) - - @bigbrother_group.command(name='watched', aliases=('all', 'list')) - @with_role(*MODERATION_ROLES) - async def watched_command( - self, ctx: Context, oldest_first: bool = False, update_cache: bool = True - ) -> None: - """ - Shows the users that are currently being monitored by Big Brother. - - The optional kwarg `oldest_first` can be used to order the list by oldest watched. - - The optional kwarg `update_cache` can be used to update the user - cache using the API before listing the users. - """ - await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) - - @bigbrother_group.command(name='oldest') - @with_role(*MODERATION_ROLES) - async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: - """ - Shows Big Brother monitored users ordered by oldest watched. - - The optional kwarg `update_cache` can be used to update the user - cache using the API before listing the users. - """ - await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) - - @bigbrother_group.command(name='watch', aliases=('w',)) - @with_role(*MODERATION_ROLES) - async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """ - Relay messages sent by the given `user` to the `#big-brother` channel. - - A `reason` for adding the user to Big Brother is required and will be displayed - in the header when relaying messages of this user to the watchchannel. - """ - await self.apply_watch(ctx, user, reason) - - @bigbrother_group.command(name='unwatch', aliases=('uw',)) - @with_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """Stop relaying messages by the given `user`.""" - await self.apply_unwatch(ctx, user, reason) - - async def apply_watch(self, ctx: Context, user: FetchedMember, reason: str) -> None: - """ - Add `user` to watched users and apply a watch infraction with `reason`. - - A message indicating the result of the operation is sent to `ctx`. - The message will include `user`'s previous watch infraction history, if it exists. - """ - if user.bot: - await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") - return - - if not await self.fetch_user_cache(): - await ctx.send(f":x: Updating the user cache failed, can't watch user {user}") - return - - if user.id in self.watched_users: - await ctx.send(f":x: {user} is already being watched.") - return - - response = await post_infraction(ctx, user, 'watch', reason, hidden=True, active=True) - - if response is not None: - self.watched_users[user.id] = response - msg = f":white_check_mark: Messages sent by {user} will now be relayed to Big Brother." - - history = await self.bot.api_client.get( - self.api_endpoint, - params={ - "user__id": str(user.id), - "active": "false", - 'type': 'watch', - 'ordering': '-inserted_at' - } - ) - - if len(history) > 1: - total = f"({len(history) // 2} previous infractions in total)" - end_reason = textwrap.shorten(history[0]["reason"], width=500, placeholder="...") - start_reason = f"Watched: {textwrap.shorten(history[1]['reason'], width=500, placeholder='...')}" - msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}\n\n{end_reason}```" - else: - msg = ":x: Failed to post the infraction: response was empty." - - await ctx.send(msg) - - async def apply_unwatch(self, ctx: Context, user: FetchedMember, reason: str, send_message: bool = True) -> None: - """ - Remove `user` from watched users and mark their infraction as inactive with `reason`. - - If `send_message` is True, a message indicating the result of the operation is sent to - `ctx`. - """ - active_watches = await self.bot.api_client.get( - self.api_endpoint, - params=ChainMap( - self.api_default_params, - {"user__id": str(user.id)} - ) - ) - if active_watches: - log.trace("Active watches for user found. Attempting to remove.") - [infraction] = active_watches - - await self.bot.api_client.patch( - f"{self.api_endpoint}/{infraction['id']}", - json={'active': False} - ) - - await post_infraction(ctx, user, 'watch', f"Unwatched: {reason}", hidden=True, active=False) - - self._remove_user(user.id) - - if not send_message: # Prevents a message being sent to the channel if part of a permanent ban - log.debug(f"Perma-banned user {user} was unwatched.") - return - log.trace("User is not banned. Sending message to channel") - message = f":white_check_mark: Messages sent by {user} will no longer be relayed." - - else: - log.trace("No active watches found for user.") - if not send_message: # Prevents a message being sent to the channel if part of a permanent ban - log.debug(f"{user} was not on the watch list; no removal necessary.") - return - log.trace("User is not perma banned. Send the error message.") - message = ":x: The specified user is currently not being watched." - - await ctx.send(message) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py deleted file mode 100644 index 89256e92e..000000000 --- a/bot/cogs/watchchannels/talentpool.py +++ /dev/null @@ -1,264 +0,0 @@ -import logging -import textwrap -from collections import ChainMap - -from discord import Color, Embed, Member -from discord.ext.commands import Cog, Context, group - -from bot.api import ResponseCodeError -from bot.bot import Bot -from bot.constants import Channels, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks -from bot.converters import FetchedMember -from bot.decorators import with_role -from bot.pagination import LinePaginator -from bot.utils import time -from .watchchannel import WatchChannel - -log = logging.getLogger(__name__) - - -class TalentPool(WatchChannel, Cog, name="Talentpool"): - """Relays messages of helper candidates to a watch channel to observe them.""" - - def __init__(self, bot: Bot) -> None: - super().__init__( - bot, - destination=Channels.talent_pool, - webhook_id=Webhooks.talent_pool, - api_endpoint='bot/nominations', - api_default_params={'active': 'true', 'ordering': '-inserted_at'}, - logger=log, - ) - - @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True) - @with_role(*MODERATION_ROLES) - async def nomination_group(self, ctx: Context) -> None: - """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" - await ctx.send_help(ctx.command) - - @nomination_group.command(name='watched', aliases=('all', 'list')) - @with_role(*MODERATION_ROLES) - async def watched_command( - self, ctx: Context, oldest_first: bool = False, update_cache: bool = True - ) -> None: - """ - Shows the users that are currently being monitored in the talent pool. - - The optional kwarg `oldest_first` can be used to order the list by oldest nomination. - - The optional kwarg `update_cache` can be used to update the user - cache using the API before listing the users. - """ - await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) - - @nomination_group.command(name='oldest') - @with_role(*MODERATION_ROLES) - async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: - """ - Shows talent pool monitored users ordered by oldest nomination. - - The optional kwarg `update_cache` can be used to update the user - cache using the API before listing the users. - """ - await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) - - @nomination_group.command(name='watch', aliases=('w', 'add', 'a')) - @with_role(*STAFF_ROLES) - async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """ - Relay messages sent by the given `user` to the `#talent-pool` channel. - - A `reason` for adding the user to the talent pool is required and will be displayed - in the header when relaying messages of this user to the channel. - """ - if user.bot: - await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") - return - - if isinstance(user, Member) and any(role.id in STAFF_ROLES for role in user.roles): - await ctx.send(":x: Nominating staff members, eh? Here's a cookie :cookie:") - return - - if not await self.fetch_user_cache(): - await ctx.send(f":x: Failed to update the user cache; can't add {user}") - return - - if user.id in self.watched_users: - await ctx.send(f":x: {user} is already being watched in the talent pool") - return - - # Manual request with `raise_for_status` as False because we want the actual response - session = self.bot.api_client.session - url = self.bot.api_client._url_for(self.api_endpoint) - kwargs = { - 'json': { - 'actor': ctx.author.id, - 'reason': reason, - 'user': user.id - }, - 'raise_for_status': False, - } - async with session.post(url, **kwargs) as resp: - response_data = await resp.json() - - if resp.status == 400 and response_data.get('user', False): - await ctx.send(":x: The specified user can't be found in the database tables") - return - else: - resp.raise_for_status() - - self.watched_users[user.id] = response_data - msg = f":white_check_mark: Messages sent by {user} will now be relayed to the talent pool channel" - - history = await self.bot.api_client.get( - self.api_endpoint, - params={ - "user__id": str(user.id), - "active": "false", - "ordering": "-inserted_at" - } - ) - - if history: - total = f"({len(history)} previous nominations in total)" - start_reason = f"Watched: {textwrap.shorten(history[0]['reason'], width=500, placeholder='...')}" - end_reason = f"Unwatched: {textwrap.shorten(history[0]['end_reason'], width=500, placeholder='...')}" - msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}\n\n{end_reason}```" - - await ctx.send(msg) - - @nomination_group.command(name='history', aliases=('info', 'search')) - @with_role(*MODERATION_ROLES) - async def history_command(self, ctx: Context, user: FetchedMember) -> None: - """Shows the specified user's nomination history.""" - result = await self.bot.api_client.get( - self.api_endpoint, - params={ - 'user__id': str(user.id), - 'ordering': "-active,-inserted_at" - } - ) - if not result: - await ctx.send(":warning: This user has never been nominated") - return - - embed = Embed( - title=f"Nominations for {user.display_name} `({user.id})`", - color=Color.blue() - ) - lines = [self._nomination_to_string(nomination) for nomination in result] - await LinePaginator.paginate( - lines, - ctx=ctx, - embed=embed, - empty=True, - max_lines=3, - max_size=1000 - ) - - @nomination_group.command(name='unwatch', aliases=('end', )) - @with_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """ - Ends the active nomination of the specified user with the given reason. - - Providing a `reason` is required. - """ - active_nomination = await self.bot.api_client.get( - self.api_endpoint, - params=ChainMap( - self.api_default_params, - {"user__id": str(user.id)} - ) - ) - - if not active_nomination: - await ctx.send(":x: The specified user does not have an active nomination") - return - - [nomination] = active_nomination - await self.bot.api_client.patch( - f"{self.api_endpoint}/{nomination['id']}", - json={'end_reason': reason, 'active': False} - ) - await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed") - self._remove_user(user.id) - - @nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True) - @with_role(*MODERATION_ROLES) - async def nomination_edit_group(self, ctx: Context) -> None: - """Commands to edit nominations.""" - await ctx.send_help(ctx.command) - - @nomination_edit_group.command(name='reason') - @with_role(*MODERATION_ROLES) - async def edit_reason_command(self, ctx: Context, nomination_id: int, *, reason: str) -> None: - """ - Edits the reason/unnominate reason for the nomination with the given `id` depending on the status. - - If the nomination is active, the reason for nominating the user will be edited; - If the nomination is no longer active, the reason for ending the nomination will be edited instead. - """ - try: - nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}") - except ResponseCodeError as e: - if e.response.status == 404: - self.log.trace(f"Nomination API 404: Can't nomination with id {nomination_id}") - await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`") - return - else: - raise - - field = "reason" if nomination["active"] else "end_reason" - - self.log.trace(f"Changing {field} for nomination with id {nomination_id} to {reason}") - - await self.bot.api_client.patch( - f"{self.api_endpoint}/{nomination_id}", - json={field: reason} - ) - - await ctx.send(f":white_check_mark: Updated the {field} of the nomination!") - - def _nomination_to_string(self, nomination_object: dict) -> str: - """Creates a string representation of a nomination.""" - guild = self.bot.get_guild(Guild.id) - - actor_id = nomination_object["actor"] - actor = guild.get_member(actor_id) - - active = nomination_object["active"] - log.debug(active) - log.debug(type(nomination_object["inserted_at"])) - - start_date = time.format_infraction(nomination_object["inserted_at"]) - if active: - lines = textwrap.dedent( - f""" - =============== - Status: **Active** - Date: {start_date} - Actor: {actor.mention if actor else actor_id} - Reason: {nomination_object["reason"]} - Nomination ID: `{nomination_object["id"]}` - =============== - """ - ) - else: - end_date = time.format_infraction(nomination_object["ended_at"]) - lines = textwrap.dedent( - f""" - =============== - Status: Inactive - Date: {start_date} - Actor: {actor.mention if actor else actor_id} - Reason: {nomination_object["reason"]} - - End date: {end_date} - Unwatch reason: {nomination_object["end_reason"]} - Nomination ID: `{nomination_object["id"]}` - =============== - """ - ) - - return lines.strip() diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py deleted file mode 100644 index 044077350..000000000 --- a/bot/cogs/watchchannels/watchchannel.py +++ /dev/null @@ -1,348 +0,0 @@ -import asyncio -import logging -import re -import textwrap -from abc import abstractmethod -from collections import defaultdict, deque -from dataclasses import dataclass -from typing import Optional - -import dateutil.parser -import discord -from discord import Color, DMChannel, Embed, HTTPException, Message, errors -from discord.ext.commands import Cog, Context - -from bot.api import ResponseCodeError -from bot.bot import Bot -from bot.cogs.moderation import ModLog -from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons -from bot.pagination import LinePaginator -from bot.utils import CogABCMeta, messages -from bot.utils.time import time_since - -log = logging.getLogger(__name__) - -URL_RE = re.compile(r"(https?://[^\s]+)") - - -@dataclass -class MessageHistory: - """Represents a watch channel's message history.""" - - last_author: Optional[int] = None - last_channel: Optional[int] = None - message_count: int = 0 - - -class WatchChannel(metaclass=CogABCMeta): - """ABC with functionality for relaying users' messages to a certain channel.""" - - @abstractmethod - def __init__( - self, - bot: Bot, - destination: int, - webhook_id: int, - api_endpoint: str, - api_default_params: dict, - logger: logging.Logger - ) -> None: - self.bot = bot - - self.destination = destination # E.g., Channels.big_brother_logs - self.webhook_id = webhook_id # E.g., Webhooks.big_brother - self.api_endpoint = api_endpoint # E.g., 'bot/infractions' - self.api_default_params = api_default_params # E.g., {'active': 'true', 'type': 'watch'} - self.log = logger # Logger of the child cog for a correct name in the logs - - self._consume_task = None - self.watched_users = defaultdict(dict) - self.message_queue = defaultdict(lambda: defaultdict(deque)) - self.consumption_queue = {} - self.retries = 5 - self.retry_delay = 10 - self.channel = None - self.webhook = None - self.message_history = MessageHistory() - - self._start = self.bot.loop.create_task(self.start_watchchannel()) - - @property - def modlog(self) -> ModLog: - """Provides access to the ModLog cog for alert purposes.""" - return self.bot.get_cog("ModLog") - - @property - def consuming_messages(self) -> bool: - """Checks if a consumption task is currently running.""" - if self._consume_task is None: - return False - - if self._consume_task.done(): - exc = self._consume_task.exception() - if exc: - self.log.exception( - "The message queue consume task has failed with:", - exc_info=exc - ) - return False - - return True - - async def start_watchchannel(self) -> None: - """Starts the watch channel by getting the channel, webhook, and user cache ready.""" - await self.bot.wait_until_guild_available() - - try: - self.channel = await self.bot.fetch_channel(self.destination) - except HTTPException: - self.log.exception(f"Failed to retrieve the text channel with id `{self.destination}`") - - try: - self.webhook = await self.bot.fetch_webhook(self.webhook_id) - except discord.HTTPException: - self.log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") - - if self.channel is None or self.webhook is None: - self.log.error("Failed to start the watch channel; unloading the cog.") - - message = textwrap.dedent( - f""" - An error occurred while loading the text channel or webhook. - - TextChannel: {"**Failed to load**" if self.channel is None else "Loaded successfully"} - Webhook: {"**Failed to load**" if self.webhook is None else "Loaded successfully"} - - The Cog has been unloaded. - """ - ) - - await self.modlog.send_log_message( - title=f"Error: Failed to initialize the {self.__class__.__name__} watch channel", - text=message, - ping_everyone=True, - icon_url=Icons.token_removed, - colour=Color.red() - ) - - self.bot.remove_cog(self.__class__.__name__) - return - - if not await self.fetch_user_cache(): - await self.modlog.send_log_message( - title=f"Warning: Failed to retrieve user cache for the {self.__class__.__name__} watch channel", - text="Could not retrieve the list of watched users from the API and messages will not be relayed.", - ping_everyone=True, - icon_url=Icons.token_removed, - colour=Color.red() - ) - - async def fetch_user_cache(self) -> bool: - """ - Fetches watched users from the API and updates the watched user cache accordingly. - - This function returns `True` if the update succeeded. - """ - try: - data = await self.bot.api_client.get(self.api_endpoint, params=self.api_default_params) - except ResponseCodeError as err: - self.log.exception("Failed to fetch the watched users from the API", exc_info=err) - return False - - self.watched_users = defaultdict(dict) - - for entry in data: - user_id = entry.pop('user') - self.watched_users[user_id] = entry - - return True - - @Cog.listener() - async def on_message(self, msg: Message) -> None: - """Queues up messages sent by watched users.""" - if msg.author.id in self.watched_users: - if not self.consuming_messages: - self._consume_task = self.bot.loop.create_task(self.consume_messages()) - - self.log.trace(f"Received message: {msg.content} ({len(msg.attachments)} attachments)") - self.message_queue[msg.author.id][msg.channel.id].append(msg) - - async def consume_messages(self, delay_consumption: bool = True) -> None: - """Consumes the message queues to log watched users' messages.""" - if delay_consumption: - self.log.trace(f"Sleeping {BigBrotherConfig.log_delay} seconds before consuming message queue") - await asyncio.sleep(BigBrotherConfig.log_delay) - - self.log.trace("Started consuming the message queue") - - # If the previous consumption Task failed, first consume the existing comsumption_queue - if not self.consumption_queue: - self.consumption_queue = self.message_queue.copy() - self.message_queue.clear() - - for user_channel_queues in self.consumption_queue.values(): - for channel_queue in user_channel_queues.values(): - while channel_queue: - msg = channel_queue.popleft() - - self.log.trace(f"Consuming message {msg.id} ({len(msg.attachments)} attachments)") - await self.relay_message(msg) - - self.consumption_queue.clear() - - if self.message_queue: - self.log.trace("Channel queue not empty: Continuing consuming queues") - self._consume_task = self.bot.loop.create_task(self.consume_messages(delay_consumption=False)) - else: - self.log.trace("Done consuming messages.") - - async def webhook_send( - self, - content: Optional[str] = None, - username: Optional[str] = None, - avatar_url: Optional[str] = None, - embed: Optional[Embed] = None, - ) -> None: - """Sends a message to the webhook with the specified kwargs.""" - username = messages.sub_clyde(username) - try: - await self.webhook.send(content=content, username=username, avatar_url=avatar_url, embed=embed) - except discord.HTTPException as exc: - self.log.exception( - "Failed to send a message to the webhook", - exc_info=exc - ) - - async def relay_message(self, msg: Message) -> None: - """Relays the message to the relevant watch channel.""" - limit = BigBrotherConfig.header_message_limit - - if ( - msg.author.id != self.message_history.last_author - or msg.channel.id != self.message_history.last_channel - or self.message_history.message_count >= limit - ): - self.message_history = MessageHistory(last_author=msg.author.id, last_channel=msg.channel.id) - - await self.send_header(msg) - - cleaned_content = msg.clean_content - - if cleaned_content: - # Put all non-media URLs in a code block to prevent embeds - media_urls = {embed.url for embed in msg.embeds if embed.type in ("image", "video")} - for url in URL_RE.findall(cleaned_content): - if url not in media_urls: - cleaned_content = cleaned_content.replace(url, f"`{url}`") - await self.webhook_send( - cleaned_content, - username=msg.author.display_name, - avatar_url=msg.author.avatar_url - ) - - if msg.attachments: - try: - await messages.send_attachments(msg, self.webhook) - except (errors.Forbidden, errors.NotFound): - e = Embed( - description=":x: **This message contained an attachment, but it could not be retrieved**", - color=Color.red() - ) - await self.webhook_send( - embed=e, - username=msg.author.display_name, - avatar_url=msg.author.avatar_url - ) - except discord.HTTPException as exc: - self.log.exception( - "Failed to send an attachment to the webhook", - exc_info=exc - ) - - self.message_history.message_count += 1 - - async def send_header(self, msg: Message) -> None: - """Sends a header embed with information about the relayed messages to the watch channel.""" - user_id = msg.author.id - - guild = self.bot.get_guild(GuildConfig.id) - actor = guild.get_member(self.watched_users[user_id]['actor']) - actor = actor.display_name if actor else self.watched_users[user_id]['actor'] - - inserted_at = self.watched_users[user_id]['inserted_at'] - time_delta = self._get_time_delta(inserted_at) - - reason = self.watched_users[user_id]['reason'] - - if isinstance(msg.channel, DMChannel): - # If a watched user DMs the bot there won't be a channel name or jump URL - # This could technically include a GroupChannel but bot's can't be in those - message_jump = "via DM" - else: - message_jump = f"in [#{msg.channel.name}]({msg.jump_url})" - - footer = f"Added {time_delta} by {actor} | Reason: {reason}" - embed = Embed(description=f"{msg.author.mention} {message_jump}") - embed.set_footer(text=textwrap.shorten(footer, width=128, placeholder="...")) - - await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.avatar_url) - - async def list_watched_users( - self, ctx: Context, oldest_first: bool = False, update_cache: bool = True - ) -> None: - """ - Gives an overview of the watched user list for this channel. - - The optional kwarg `oldest_first` orders the list by oldest entry. - - The optional kwarg `update_cache` specifies whether the cache should - be refreshed by polling the API. - """ - if update_cache: - if not await self.fetch_user_cache(): - await ctx.send(f":x: Failed to update {self.__class__.__name__} user cache, serving from cache") - update_cache = False - - lines = [] - for user_id, user_data in self.watched_users.items(): - inserted_at = user_data['inserted_at'] - time_delta = self._get_time_delta(inserted_at) - lines.append(f"• <@{user_id}> (added {time_delta})") - - if oldest_first: - lines.reverse() - - lines = lines or ("There's nothing here yet.",) - - embed = Embed( - title=f"{self.__class__.__name__} watched users ({'updated' if update_cache else 'cached'})", - color=Color.blue() - ) - await LinePaginator.paginate(lines, ctx, embed, empty=False) - - @staticmethod - def _get_time_delta(time_string: str) -> str: - """Returns the time in human-readable time delta format.""" - date_time = dateutil.parser.isoparse(time_string).replace(tzinfo=None) - time_delta = time_since(date_time, precision="minutes", max_units=1) - - return time_delta - - def _remove_user(self, user_id: int) -> None: - """Removes a user from a watch channel.""" - self.watched_users.pop(user_id, None) - self.message_queue.pop(user_id, None) - self.consumption_queue.pop(user_id, None) - - def cog_unload(self) -> None: - """Takes care of unloading the cog and canceling the consumption task.""" - self.log.trace("Unloading the cog") - if self._consume_task and not self._consume_task.done(): - self._consume_task.cancel() - try: - self._consume_task.result() - except asyncio.CancelledError as e: - self.log.exception( - "The consume task was canceled. Messages may be lost.", - exc_info=e - ) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py deleted file mode 100644 index 5812da87c..000000000 --- a/bot/cogs/webhook_remover.py +++ /dev/null @@ -1,84 +0,0 @@ -import logging -import re - -from discord import Colour, Message, NotFound -from discord.ext.commands import Cog - -from bot.bot import Bot -from bot.cogs.moderation.modlog import ModLog -from bot.constants import Channels, Colours, Event, Icons - -WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\d+/)\S+/?", re.IGNORECASE) - -ALERT_MESSAGE_TEMPLATE = ( - "{user}, looks like you posted a Discord webhook URL. Therefore, your " - "message has been removed. Your webhook may have been **compromised** so " - "please re-create the webhook **immediately**. If you believe this was " - "mistake, please let us know." -) - -log = logging.getLogger(__name__) - - -class WebhookRemover(Cog): - """Scan messages to detect Discord webhooks links.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @property - def mod_log(self) -> ModLog: - """Get current instance of `ModLog`.""" - return self.bot.get_cog("ModLog") - - async def delete_and_respond(self, msg: Message, redacted_url: str) -> None: - """Delete `msg` and send a warning that it contained the Discord webhook `redacted_url`.""" - # Don't log this, due internal delete, not by user. Will make different entry. - self.mod_log.ignore(Event.message_delete, msg.id) - - try: - await msg.delete() - except NotFound: - log.debug(f"Failed to remove webhook in message {msg.id}: message already deleted.") - return - - await msg.channel.send(ALERT_MESSAGE_TEMPLATE.format(user=msg.author.mention)) - - message = ( - f"{msg.author} (`{msg.author.id}`) posted a Discord webhook URL " - f"to #{msg.channel}. Webhook URL was `{redacted_url}`" - ) - log.debug(message) - - # Send entry to moderation alerts. - await self.mod_log.send_log_message( - icon_url=Icons.token_removed, - colour=Colour(Colours.soft_red), - title="Discord webhook URL removed!", - text=message, - thumbnail=msg.author.avatar_url_as(static_format="png"), - channel_id=Channels.mod_alerts - ) - - self.bot.stats.incr("tokens.removed_webhooks") - - @Cog.listener() - async def on_message(self, msg: Message) -> None: - """Check if a Discord webhook URL is in `message`.""" - # Ignore DMs; can't delete messages in there anyway. - if not msg.guild or msg.author.bot: - return - - matches = WEBHOOK_URL_RE.search(msg.content) - if matches: - await self.delete_and_respond(msg, matches[1] + "xxx") - - @Cog.listener() - async def on_message_edit(self, before: Message, after: Message) -> None: - """Check if a Discord webhook URL is in the edited message `after`.""" - await self.on_message(after) - - -def setup(bot: Bot) -> None: - """Load `WebhookRemover` cog.""" - bot.add_cog(WebhookRemover(bot)) diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py deleted file mode 100644 index e6cae3bb8..000000000 --- a/bot/cogs/wolfram.py +++ /dev/null @@ -1,280 +0,0 @@ -import logging -from io import BytesIO -from typing import Callable, List, Optional, Tuple -from urllib import parse - -import discord -from dateutil.relativedelta import relativedelta -from discord import Embed -from discord.ext import commands -from discord.ext.commands import BucketType, Cog, Context, check, group - -from bot.bot import Bot -from bot.constants import Colours, STAFF_ROLES, Wolfram -from bot.pagination import ImagePaginator -from bot.utils.time import humanize_delta - -log = logging.getLogger(__name__) - -APPID = Wolfram.key -DEFAULT_OUTPUT_FORMAT = "JSON" -QUERY = "http://api.wolframalpha.com/v2/{request}?{data}" -WOLF_IMAGE = "https://www.symbols.com/gi.php?type=1&id=2886&i=1" - -MAX_PODS = 20 - -# Allows for 10 wolfram calls pr user pr day -usercd = commands.CooldownMapping.from_cooldown(Wolfram.user_limit_day, 60*60*24, BucketType.user) - -# Allows for max api requests / days in month per day for the entire guild (Temporary) -guildcd = commands.CooldownMapping.from_cooldown(Wolfram.guild_limit_day, 60*60*24, BucketType.guild) - - -async def send_embed( - ctx: Context, - message_txt: str, - colour: int = Colours.soft_red, - footer: str = None, - img_url: str = None, - f: discord.File = None -) -> None: - """Generate & send a response embed with Wolfram as the author.""" - embed = Embed(colour=colour) - embed.description = message_txt - embed.set_author(name="Wolfram Alpha", - icon_url=WOLF_IMAGE, - url="https://www.wolframalpha.com/") - if footer: - embed.set_footer(text=footer) - - if img_url: - embed.set_image(url=img_url) - - await ctx.send(embed=embed, file=f) - - -def custom_cooldown(*ignore: List[int]) -> Callable: - """ - Implement per-user and per-guild cooldowns for requests to the Wolfram API. - - A list of roles may be provided to ignore the per-user cooldown - """ - async def predicate(ctx: Context) -> bool: - if ctx.invoked_with == 'help': - # if the invoked command is help we don't want to increase the ratelimits since it's not actually - # invoking the command/making a request, so instead just check if the user/guild are on cooldown. - guild_cooldown = not guildcd.get_bucket(ctx.message).get_tokens() == 0 # if guild is on cooldown - if not any(r.id in ignore for r in ctx.author.roles): # check user bucket if user is not ignored - return guild_cooldown and not usercd.get_bucket(ctx.message).get_tokens() == 0 - return guild_cooldown - - user_bucket = usercd.get_bucket(ctx.message) - - if all(role.id not in ignore for role in ctx.author.roles): - user_rate = user_bucket.update_rate_limit() - - if user_rate: - # Can't use api; cause: member limit - delta = relativedelta(seconds=int(user_rate)) - cooldown = humanize_delta(delta) - message = ( - "You've used up your limit for Wolfram|Alpha requests.\n" - f"Cooldown: {cooldown}" - ) - await send_embed(ctx, message) - return False - - guild_bucket = guildcd.get_bucket(ctx.message) - guild_rate = guild_bucket.update_rate_limit() - - # Repr has a token attribute to read requests left - log.debug(guild_bucket) - - if guild_rate: - # Can't use api; cause: guild limit - message = ( - "The max limit of requests for the server has been reached for today.\n" - f"Cooldown: {int(guild_rate)}" - ) - await send_embed(ctx, message) - return False - - return True - return check(predicate) - - -async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[List[Tuple]]: - """Get the Wolfram API pod pages for the provided query.""" - async with ctx.channel.typing(): - url_str = parse.urlencode({ - "input": query, - "appid": APPID, - "output": DEFAULT_OUTPUT_FORMAT, - "format": "image,plaintext" - }) - request_url = QUERY.format(request="query", data=url_str) - - async with bot.http_session.get(request_url) as response: - json = await response.json(content_type='text/plain') - - result = json["queryresult"] - - if result["error"]: - # API key not set up correctly - if result["error"]["msg"] == "Invalid appid": - message = "Wolfram API key is invalid or missing." - log.warning( - "API key seems to be missing, or invalid when " - f"processing a wolfram request: {url_str}, Response: {json}" - ) - await send_embed(ctx, message) - return - - message = "Something went wrong internally with your request, please notify staff!" - log.warning(f"Something went wrong getting a response from wolfram: {url_str}, Response: {json}") - await send_embed(ctx, message) - return - - if not result["success"]: - message = f"I couldn't find anything for {query}." - await send_embed(ctx, message) - return - - if not result["numpods"]: - message = "Could not find any results." - await send_embed(ctx, message) - return - - pods = result["pods"] - pages = [] - for pod in pods[:MAX_PODS]: - subs = pod.get("subpods") - - for sub in subs: - title = sub.get("title") or sub.get("plaintext") or sub.get("id", "") - img = sub["img"]["src"] - pages.append((title, img)) - return pages - - -class Wolfram(Cog): - """Commands for interacting with the Wolfram|Alpha API.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @group(name="wolfram", aliases=("wolf", "wa"), invoke_without_command=True) - @custom_cooldown(*STAFF_ROLES) - async def wolfram_command(self, ctx: Context, *, query: str) -> None: - """Requests all answers on a single image, sends an image of all related pods.""" - url_str = parse.urlencode({ - "i": query, - "appid": APPID, - }) - query = QUERY.format(request="simple", data=url_str) - - # Give feedback that the bot is working. - async with ctx.channel.typing(): - async with self.bot.http_session.get(query) as response: - status = response.status - image_bytes = await response.read() - - f = discord.File(BytesIO(image_bytes), filename="image.png") - image_url = "attachment://image.png" - - if status == 501: - message = "Failed to get response" - footer = "" - color = Colours.soft_red - elif status == 400: - message = "No input found" - footer = "" - color = Colours.soft_red - elif status == 403: - message = "Wolfram API key is invalid or missing." - footer = "" - color = Colours.soft_red - else: - message = "" - footer = "View original for a bigger picture." - color = Colours.soft_orange - - # Sends a "blank" embed if no request is received, unsure how to fix - await send_embed(ctx, message, color, footer=footer, img_url=image_url, f=f) - - @wolfram_command.command(name="page", aliases=("pa", "p")) - @custom_cooldown(*STAFF_ROLES) - async def wolfram_page_command(self, ctx: Context, *, query: str) -> None: - """ - Requests a drawn image of given query. - - Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. - """ - pages = await get_pod_pages(ctx, self.bot, query) - - if not pages: - return - - embed = Embed() - embed.set_author(name="Wolfram Alpha", - icon_url=WOLF_IMAGE, - url="https://www.wolframalpha.com/") - embed.colour = Colours.soft_orange - - await ImagePaginator.paginate(pages, ctx, embed) - - @wolfram_command.command(name="cut", aliases=("c",)) - @custom_cooldown(*STAFF_ROLES) - async def wolfram_cut_command(self, ctx: Context, *, query: str) -> None: - """ - Requests a drawn image of given query. - - Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. - """ - pages = await get_pod_pages(ctx, self.bot, query) - - if not pages: - return - - if len(pages) >= 2: - page = pages[1] - else: - page = pages[0] - - await send_embed(ctx, page[0], colour=Colours.soft_orange, img_url=page[1]) - - @wolfram_command.command(name="short", aliases=("sh", "s")) - @custom_cooldown(*STAFF_ROLES) - async def wolfram_short_command(self, ctx: Context, *, query: str) -> None: - """Requests an answer to a simple question.""" - url_str = parse.urlencode({ - "i": query, - "appid": APPID, - }) - query = QUERY.format(request="result", data=url_str) - - # Give feedback that the bot is working. - async with ctx.channel.typing(): - async with self.bot.http_session.get(query) as response: - status = response.status - response_text = await response.text() - - if status == 501: - message = "Failed to get response" - color = Colours.soft_red - elif status == 400: - message = "No input found" - color = Colours.soft_red - elif response_text == "Error 1: Invalid appid": - message = "Wolfram API key is invalid or missing." - color = Colours.soft_red - else: - message = response_text - color = Colours.soft_orange - - await send_embed(ctx, message, color) - - -def setup(bot: Bot) -> None: - """Load the Wolfram cog.""" - bot.add_cog(Wolfram(bot)) diff --git a/tests/bot/cogs/moderation/test_infractions.py b/tests/bot/cogs/moderation/test_infractions.py index da4e92ccc..df38090fb 100644 --- a/tests/bot/cogs/moderation/test_infractions.py +++ b/tests/bot/cogs/moderation/test_infractions.py @@ -2,7 +2,7 @@ import textwrap import unittest from unittest.mock import AsyncMock, Mock, patch -from bot.cogs.moderation.infractions import Infractions +from bot.cogs.moderation.infraction.infractions import Infractions from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 70aea2bab..84d036405 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -6,7 +6,7 @@ import discord from bot import constants from bot.api import ResponseCodeError -from bot.cogs.sync.syncers import Syncer, _Diff +from bot.cogs.backend.sync import Syncer, _Diff from tests import helpers diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 120bc991d..ea7d090ba 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -5,8 +5,8 @@ import discord from bot import constants from bot.api import ResponseCodeError -from bot.cogs import sync -from bot.cogs.sync.syncers import Syncer +from bot.cogs.backend import sync +from bot.cogs.backend.sync import Syncer from tests import helpers from tests.base import CommandTestCase diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index 79eee98f4..888c49ca8 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -3,7 +3,7 @@ from unittest import mock import discord -from bot.cogs.sync.syncers import RoleSyncer, _Diff, _Role +from bot.cogs.backend.sync import RoleSyncer, _Diff, _Role from tests import helpers diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index 002a947ad..71f4b134c 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -1,7 +1,7 @@ import unittest from unittest import mock -from bot.cogs.sync.syncers import UserSyncer, _Diff, _User +from bot.cogs.backend.sync import UserSyncer, _Diff, _User from tests import helpers diff --git a/tests/bot/cogs/test_antimalware.py b/tests/bot/cogs/test_antimalware.py index ecb7abf00..b00211f47 100644 --- a/tests/bot/cogs/test_antimalware.py +++ b/tests/bot/cogs/test_antimalware.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, Mock from discord import NotFound -from bot.cogs import antimalware +from bot.cogs.filters import antimalware from bot.constants import Channels, STAFF_ROLES from tests.helpers import MockAttachment, MockBot, MockMessage, MockRole diff --git a/tests/bot/cogs/test_antispam.py b/tests/bot/cogs/test_antispam.py index ce5472c71..8a3d8d02e 100644 --- a/tests/bot/cogs/test_antispam.py +++ b/tests/bot/cogs/test_antispam.py @@ -1,6 +1,6 @@ import unittest -from bot.cogs import antispam +from bot.cogs.filters import antispam class AntispamConfigurationValidationTests(unittest.TestCase): diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 79c0e0ad3..305a2bad9 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -6,7 +6,7 @@ import unittest.mock import discord from bot import constants -from bot.cogs import information +from bot.cogs.info import information from bot.utils.checks import InWhitelistCheckFailure from tests import helpers diff --git a/tests/bot/cogs/test_security.py b/tests/bot/cogs/test_security.py index 9d1a62f7e..82679f69c 100644 --- a/tests/bot/cogs/test_security.py +++ b/tests/bot/cogs/test_security.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock from discord.ext.commands import NoPrivateMessage -from bot.cogs import security +from bot.cogs.filters import security from tests.helpers import MockBot, MockContext diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 343e37db9..c7bac3ab3 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -6,8 +6,8 @@ from unittest.mock import AsyncMock, MagicMock, Mock, call, create_autospec, pat from discord.ext import commands from bot import constants -from bot.cogs import snekbox -from bot.cogs.snekbox import Snekbox +from bot.cogs.utils import snekbox +from bot.cogs.utils.snekbox import Snekbox from tests.helpers import MockBot, MockContext, MockMessage, MockReaction, MockUser diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 3349caa73..e33f3af38 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -6,9 +6,9 @@ from unittest.mock import MagicMock from discord import Colour, NotFound from bot import constants -from bot.cogs import token_remover +from bot.cogs.filters import token_remover +from bot.cogs.filters.token_remover import Token, TokenRemover from bot.cogs.moderation import ModLog -from bot.cogs.token_remover import Token, TokenRemover from tests.helpers import MockBot, MockMessage, autospec -- cgit v1.2.3 From b224d46d68699ece3382cd333df7ede9e9a62e02 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 12 Aug 2020 14:31:56 -0700 Subject: Restructure tests and fix broken tests The cog tests structure should mirror the structure of the cogs folder. Fix some import/patch paths which broke due to the restructure. --- tests/bot/cogs/backend/__init__.py | 0 tests/bot/cogs/backend/sync/__init__.py | 0 tests/bot/cogs/backend/sync/test_base.py | 404 ++++++++++++++ tests/bot/cogs/backend/sync/test_cog.py | 415 +++++++++++++++ tests/bot/cogs/backend/sync/test_roles.py | 157 ++++++ tests/bot/cogs/backend/sync/test_users.py | 158 ++++++ tests/bot/cogs/backend/test_logging.py | 32 ++ tests/bot/cogs/filters/__init__.py | 0 tests/bot/cogs/filters/test_antimalware.py | 165 ++++++ tests/bot/cogs/filters/test_antispam.py | 35 ++ tests/bot/cogs/filters/test_security.py | 54 ++ tests/bot/cogs/filters/test_token_remover.py | 310 +++++++++++ tests/bot/cogs/info/__init__.py | 0 tests/bot/cogs/info/test_information.py | 584 +++++++++++++++++++++ tests/bot/cogs/moderation/infraction/__init__.py | 0 .../cogs/moderation/infraction/test_infractions.py | 55 ++ tests/bot/cogs/moderation/test_incidents.py | 4 +- tests/bot/cogs/moderation/test_infractions.py | 55 -- tests/bot/cogs/moderation/test_slowmode.py | 111 ++++ tests/bot/cogs/sync/__init__.py | 0 tests/bot/cogs/sync/test_base.py | 404 -------------- tests/bot/cogs/sync/test_cog.py | 415 --------------- tests/bot/cogs/sync/test_roles.py | 157 ------ tests/bot/cogs/sync/test_users.py | 158 ------ tests/bot/cogs/test_antimalware.py | 165 ------ tests/bot/cogs/test_antispam.py | 35 -- tests/bot/cogs/test_information.py | 584 --------------------- tests/bot/cogs/test_jams.py | 173 ------ tests/bot/cogs/test_logging.py | 32 -- tests/bot/cogs/test_security.py | 54 -- tests/bot/cogs/test_slowmode.py | 111 ---- tests/bot/cogs/test_snekbox.py | 409 --------------- tests/bot/cogs/test_token_remover.py | 310 ----------- tests/bot/cogs/utils/__init__.py | 0 tests/bot/cogs/utils/test_jams.py | 173 ++++++ tests/bot/cogs/utils/test_snekbox.py | 409 +++++++++++++++ 36 files changed, 3064 insertions(+), 3064 deletions(-) create mode 100644 tests/bot/cogs/backend/__init__.py create mode 100644 tests/bot/cogs/backend/sync/__init__.py create mode 100644 tests/bot/cogs/backend/sync/test_base.py create mode 100644 tests/bot/cogs/backend/sync/test_cog.py create mode 100644 tests/bot/cogs/backend/sync/test_roles.py create mode 100644 tests/bot/cogs/backend/sync/test_users.py create mode 100644 tests/bot/cogs/backend/test_logging.py create mode 100644 tests/bot/cogs/filters/__init__.py create mode 100644 tests/bot/cogs/filters/test_antimalware.py create mode 100644 tests/bot/cogs/filters/test_antispam.py create mode 100644 tests/bot/cogs/filters/test_security.py create mode 100644 tests/bot/cogs/filters/test_token_remover.py create mode 100644 tests/bot/cogs/info/__init__.py create mode 100644 tests/bot/cogs/info/test_information.py create mode 100644 tests/bot/cogs/moderation/infraction/__init__.py create mode 100644 tests/bot/cogs/moderation/infraction/test_infractions.py delete mode 100644 tests/bot/cogs/moderation/test_infractions.py create mode 100644 tests/bot/cogs/moderation/test_slowmode.py delete mode 100644 tests/bot/cogs/sync/__init__.py delete mode 100644 tests/bot/cogs/sync/test_base.py delete mode 100644 tests/bot/cogs/sync/test_cog.py delete mode 100644 tests/bot/cogs/sync/test_roles.py delete mode 100644 tests/bot/cogs/sync/test_users.py delete mode 100644 tests/bot/cogs/test_antimalware.py delete mode 100644 tests/bot/cogs/test_antispam.py delete mode 100644 tests/bot/cogs/test_information.py delete mode 100644 tests/bot/cogs/test_jams.py delete mode 100644 tests/bot/cogs/test_logging.py delete mode 100644 tests/bot/cogs/test_security.py delete mode 100644 tests/bot/cogs/test_slowmode.py delete mode 100644 tests/bot/cogs/test_snekbox.py delete mode 100644 tests/bot/cogs/test_token_remover.py create mode 100644 tests/bot/cogs/utils/__init__.py create mode 100644 tests/bot/cogs/utils/test_jams.py create mode 100644 tests/bot/cogs/utils/test_snekbox.py diff --git a/tests/bot/cogs/backend/__init__.py b/tests/bot/cogs/backend/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/cogs/backend/sync/__init__.py b/tests/bot/cogs/backend/sync/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/cogs/backend/sync/test_base.py b/tests/bot/cogs/backend/sync/test_base.py new file mode 100644 index 000000000..0d0a8299d --- /dev/null +++ b/tests/bot/cogs/backend/sync/test_base.py @@ -0,0 +1,404 @@ +import asyncio +import unittest +from unittest import mock + +import discord + +from bot import constants +from bot.api import ResponseCodeError +from bot.cogs.backend.sync.syncers import Syncer, _Diff +from tests import helpers + + +class TestSyncer(Syncer): + """Syncer subclass with mocks for abstract methods for testing purposes.""" + + name = "test" + _get_diff = mock.AsyncMock() + _sync = mock.AsyncMock() + + +class SyncerBaseTests(unittest.TestCase): + """Tests for the syncer base class.""" + + def setUp(self): + self.bot = helpers.MockBot() + + def test_instantiation_fails_without_abstract_methods(self): + """The class must have abstract methods implemented.""" + with self.assertRaisesRegex(TypeError, "Can't instantiate abstract class"): + Syncer(self.bot) + + +class SyncerSendPromptTests(unittest.IsolatedAsyncioTestCase): + """Tests for sending the sync confirmation prompt.""" + + def setUp(self): + self.bot = helpers.MockBot() + self.syncer = TestSyncer(self.bot) + + def mock_get_channel(self): + """Fixture to return a mock channel and message for when `get_channel` is used.""" + self.bot.reset_mock() + + mock_channel = helpers.MockTextChannel() + mock_message = helpers.MockMessage() + + mock_channel.send.return_value = mock_message + self.bot.get_channel.return_value = mock_channel + + return mock_channel, mock_message + + def mock_fetch_channel(self): + """Fixture to return a mock channel and message for when `fetch_channel` is used.""" + self.bot.reset_mock() + + mock_channel = helpers.MockTextChannel() + mock_message = helpers.MockMessage() + + self.bot.get_channel.return_value = None + mock_channel.send.return_value = mock_message + self.bot.fetch_channel.return_value = mock_channel + + return mock_channel, mock_message + + async def test_send_prompt_edits_and_returns_message(self): + """The given message should be edited to display the prompt and then should be returned.""" + msg = helpers.MockMessage() + ret_val = await self.syncer._send_prompt(msg) + + msg.edit.assert_called_once() + self.assertIn("content", msg.edit.call_args[1]) + self.assertEqual(ret_val, msg) + + async def test_send_prompt_gets_dev_core_channel(self): + """The dev-core channel should be retrieved if an extant message isn't given.""" + subtests = ( + (self.bot.get_channel, self.mock_get_channel), + (self.bot.fetch_channel, self.mock_fetch_channel), + ) + + for method, mock_ in subtests: + with self.subTest(method=method, msg=mock_.__name__): + mock_() + await self.syncer._send_prompt() + + method.assert_called_once_with(constants.Channels.dev_core) + + async def test_send_prompt_returns_none_if_channel_fetch_fails(self): + """None should be returned if there's an HTTPException when fetching the channel.""" + self.bot.get_channel.return_value = None + self.bot.fetch_channel.side_effect = discord.HTTPException(mock.MagicMock(), "test error!") + + ret_val = await self.syncer._send_prompt() + + self.assertIsNone(ret_val) + + async def test_send_prompt_sends_and_returns_new_message_if_not_given(self): + """A new message mentioning core devs should be sent and returned if message isn't given.""" + for mock_ in (self.mock_get_channel, self.mock_fetch_channel): + with self.subTest(msg=mock_.__name__): + mock_channel, mock_message = mock_() + ret_val = await self.syncer._send_prompt() + + mock_channel.send.assert_called_once() + self.assertIn(self.syncer._CORE_DEV_MENTION, mock_channel.send.call_args[0][0]) + self.assertEqual(ret_val, mock_message) + + async def test_send_prompt_adds_reactions(self): + """The message should have reactions for confirmation added.""" + extant_message = helpers.MockMessage() + subtests = ( + (extant_message, lambda: (None, extant_message)), + (None, self.mock_get_channel), + (None, self.mock_fetch_channel), + ) + + for message_arg, mock_ in subtests: + subtest_msg = "Extant message" if mock_.__name__ == "" else mock_.__name__ + + with self.subTest(msg=subtest_msg): + _, mock_message = mock_() + await self.syncer._send_prompt(message_arg) + + calls = [mock.call(emoji) for emoji in self.syncer._REACTION_EMOJIS] + mock_message.add_reaction.assert_has_calls(calls) + + +class SyncerConfirmationTests(unittest.IsolatedAsyncioTestCase): + """Tests for waiting for a sync confirmation reaction on the prompt.""" + + def setUp(self): + self.bot = helpers.MockBot() + self.syncer = TestSyncer(self.bot) + self.core_dev_role = helpers.MockRole(id=constants.Roles.core_developers) + + @staticmethod + def get_message_reaction(emoji): + """Fixture to return a mock message an reaction from the given `emoji`.""" + message = helpers.MockMessage() + reaction = helpers.MockReaction(emoji=emoji, message=message) + + return message, reaction + + def test_reaction_check_for_valid_emoji_and_authors(self): + """Should return True if authors are identical or are a bot and a core dev, respectively.""" + user_subtests = ( + ( + helpers.MockMember(id=77), + helpers.MockMember(id=77), + "identical users", + ), + ( + helpers.MockMember(id=77, bot=True), + helpers.MockMember(id=43, roles=[self.core_dev_role]), + "bot author and core-dev reactor", + ), + ) + + for emoji in self.syncer._REACTION_EMOJIS: + for author, user, msg in user_subtests: + with self.subTest(author=author, user=user, emoji=emoji, msg=msg): + message, reaction = self.get_message_reaction(emoji) + ret_val = self.syncer._reaction_check(author, message, reaction, user) + + self.assertTrue(ret_val) + + def test_reaction_check_for_invalid_reactions(self): + """Should return False for invalid reaction events.""" + valid_emoji = self.syncer._REACTION_EMOJIS[0] + subtests = ( + ( + helpers.MockMember(id=77), + *self.get_message_reaction(valid_emoji), + helpers.MockMember(id=43, roles=[self.core_dev_role]), + "users are not identical", + ), + ( + helpers.MockMember(id=77, bot=True), + *self.get_message_reaction(valid_emoji), + helpers.MockMember(id=43), + "reactor lacks the core-dev role", + ), + ( + helpers.MockMember(id=77, bot=True, roles=[self.core_dev_role]), + *self.get_message_reaction(valid_emoji), + helpers.MockMember(id=77, bot=True, roles=[self.core_dev_role]), + "reactor is a bot", + ), + ( + helpers.MockMember(id=77), + helpers.MockMessage(id=95), + helpers.MockReaction(emoji=valid_emoji, message=helpers.MockMessage(id=26)), + helpers.MockMember(id=77), + "messages are not identical", + ), + ( + helpers.MockMember(id=77), + *self.get_message_reaction("InVaLiD"), + helpers.MockMember(id=77), + "emoji is invalid", + ), + ) + + for *args, msg in subtests: + kwargs = dict(zip(("author", "message", "reaction", "user"), args)) + with self.subTest(**kwargs, msg=msg): + ret_val = self.syncer._reaction_check(*args) + self.assertFalse(ret_val) + + async def test_wait_for_confirmation(self): + """The message should always be edited and only return True if the emoji is a check mark.""" + subtests = ( + (constants.Emojis.check_mark, True, None), + ("InVaLiD", False, None), + (None, False, asyncio.TimeoutError), + ) + + for emoji, ret_val, side_effect in subtests: + for bot in (True, False): + with self.subTest(emoji=emoji, ret_val=ret_val, side_effect=side_effect, bot=bot): + # Set up mocks + message = helpers.MockMessage() + member = helpers.MockMember(bot=bot) + + self.bot.wait_for.reset_mock() + self.bot.wait_for.return_value = (helpers.MockReaction(emoji=emoji), None) + self.bot.wait_for.side_effect = side_effect + + # Call the function + actual_return = await self.syncer._wait_for_confirmation(member, message) + + # Perform assertions + self.bot.wait_for.assert_called_once() + self.assertIn("reaction_add", self.bot.wait_for.call_args[0]) + + message.edit.assert_called_once() + kwargs = message.edit.call_args[1] + self.assertIn("content", kwargs) + + # Core devs should only be mentioned if the author is a bot. + if bot: + self.assertIn(self.syncer._CORE_DEV_MENTION, kwargs["content"]) + else: + self.assertNotIn(self.syncer._CORE_DEV_MENTION, kwargs["content"]) + + self.assertIs(actual_return, ret_val) + + +class SyncerSyncTests(unittest.IsolatedAsyncioTestCase): + """Tests for main function orchestrating the sync.""" + + def setUp(self): + self.bot = helpers.MockBot(user=helpers.MockMember(bot=True)) + self.syncer = TestSyncer(self.bot) + + async def test_sync_respects_confirmation_result(self): + """The sync should abort if confirmation fails and continue if confirmed.""" + mock_message = helpers.MockMessage() + subtests = ( + (True, mock_message), + (False, None), + ) + + for confirmed, message in subtests: + with self.subTest(confirmed=confirmed): + self.syncer._sync.reset_mock() + self.syncer._get_diff.reset_mock() + + diff = _Diff({1, 2, 3}, {4, 5}, None) + self.syncer._get_diff.return_value = diff + self.syncer._get_confirmation_result = mock.AsyncMock( + return_value=(confirmed, message) + ) + + guild = helpers.MockGuild() + await self.syncer.sync(guild) + + self.syncer._get_diff.assert_called_once_with(guild) + self.syncer._get_confirmation_result.assert_called_once() + + if confirmed: + self.syncer._sync.assert_called_once_with(diff) + else: + self.syncer._sync.assert_not_called() + + async def test_sync_diff_size(self): + """The diff size should be correctly calculated.""" + subtests = ( + (6, _Diff({1, 2}, {3, 4}, {5, 6})), + (5, _Diff({1, 2, 3}, None, {4, 5})), + (0, _Diff(None, None, None)), + (0, _Diff(set(), set(), set())), + ) + + for size, diff in subtests: + with self.subTest(size=size, diff=diff): + self.syncer._get_diff.reset_mock() + self.syncer._get_diff.return_value = diff + self.syncer._get_confirmation_result = mock.AsyncMock(return_value=(False, None)) + + guild = helpers.MockGuild() + await self.syncer.sync(guild) + + self.syncer._get_diff.assert_called_once_with(guild) + self.syncer._get_confirmation_result.assert_called_once() + self.assertEqual(self.syncer._get_confirmation_result.call_args[0][0], size) + + async def test_sync_message_edited(self): + """The message should be edited if one was sent, even if the sync has an API error.""" + subtests = ( + (None, None, False), + (helpers.MockMessage(), None, True), + (helpers.MockMessage(), ResponseCodeError(mock.MagicMock()), True), + ) + + for message, side_effect, should_edit in subtests: + with self.subTest(message=message, side_effect=side_effect, should_edit=should_edit): + self.syncer._sync.side_effect = side_effect + self.syncer._get_confirmation_result = mock.AsyncMock( + return_value=(True, message) + ) + + guild = helpers.MockGuild() + await self.syncer.sync(guild) + + if should_edit: + message.edit.assert_called_once() + self.assertIn("content", message.edit.call_args[1]) + + async def test_sync_confirmation_context_redirect(self): + """If ctx is given, a new message should be sent and author should be ctx's author.""" + mock_member = helpers.MockMember() + subtests = ( + (None, self.bot.user, None), + (helpers.MockContext(author=mock_member), mock_member, helpers.MockMessage()), + ) + + for ctx, author, message in subtests: + with self.subTest(ctx=ctx, author=author, message=message): + if ctx is not None: + ctx.send.return_value = message + + # Make sure `_get_diff` returns a MagicMock, not an AsyncMock + self.syncer._get_diff.return_value = mock.MagicMock() + + self.syncer._get_confirmation_result = mock.AsyncMock(return_value=(False, None)) + + guild = helpers.MockGuild() + await self.syncer.sync(guild, ctx) + + if ctx is not None: + ctx.send.assert_called_once() + + self.syncer._get_confirmation_result.assert_called_once() + self.assertEqual(self.syncer._get_confirmation_result.call_args[0][1], author) + self.assertEqual(self.syncer._get_confirmation_result.call_args[0][2], message) + + @mock.patch.object(constants.Sync, "max_diff", new=3) + async def test_confirmation_result_small_diff(self): + """Should always return True and the given message if the diff size is too small.""" + author = helpers.MockMember() + expected_message = helpers.MockMessage() + + for size in (3, 2): # pragma: no cover + with self.subTest(size=size): + self.syncer._send_prompt = mock.AsyncMock() + self.syncer._wait_for_confirmation = mock.AsyncMock() + + coro = self.syncer._get_confirmation_result(size, author, expected_message) + result, actual_message = await coro + + self.assertTrue(result) + self.assertEqual(actual_message, expected_message) + self.syncer._send_prompt.assert_not_called() + self.syncer._wait_for_confirmation.assert_not_called() + + @mock.patch.object(constants.Sync, "max_diff", new=3) + async def test_confirmation_result_large_diff(self): + """Should return True if confirmed and False if _send_prompt fails or aborted.""" + author = helpers.MockMember() + mock_message = helpers.MockMessage() + + subtests = ( + (True, mock_message, True, "confirmed"), + (False, None, False, "_send_prompt failed"), + (False, mock_message, False, "aborted"), + ) + + for expected_result, expected_message, confirmed, msg in subtests: # pragma: no cover + with self.subTest(msg=msg): + self.syncer._send_prompt = mock.AsyncMock(return_value=expected_message) + self.syncer._wait_for_confirmation = mock.AsyncMock(return_value=confirmed) + + coro = self.syncer._get_confirmation_result(4, author) + actual_result, actual_message = await coro + + self.syncer._send_prompt.assert_called_once_with(None) # message defaults to None + self.assertIs(actual_result, expected_result) + self.assertEqual(actual_message, expected_message) + + if expected_message: + self.syncer._wait_for_confirmation.assert_called_once_with( + author, expected_message + ) diff --git a/tests/bot/cogs/backend/sync/test_cog.py b/tests/bot/cogs/backend/sync/test_cog.py new file mode 100644 index 000000000..199747051 --- /dev/null +++ b/tests/bot/cogs/backend/sync/test_cog.py @@ -0,0 +1,415 @@ +import unittest +from unittest import mock + +import discord + +from bot import constants +from bot.api import ResponseCodeError +from bot.cogs.backend import sync +from bot.cogs.backend.sync.syncers import Syncer +from tests import helpers +from tests.base import CommandTestCase + + +class SyncExtensionTests(unittest.IsolatedAsyncioTestCase): + """Tests for the sync extension.""" + + @staticmethod + def test_extension_setup(): + """The Sync cog should be added.""" + bot = helpers.MockBot() + sync.setup(bot) + bot.add_cog.assert_called_once() + + +class SyncCogTestCase(unittest.IsolatedAsyncioTestCase): + """Base class for Sync cog tests. Sets up patches for syncers.""" + + def setUp(self): + self.bot = helpers.MockBot() + + self.role_syncer_patcher = mock.patch( + "bot.cogs.backend.sync.syncers.RoleSyncer", + autospec=Syncer, + spec_set=True + ) + self.user_syncer_patcher = mock.patch( + "bot.cogs.backend.sync.syncers.UserSyncer", + autospec=Syncer, + spec_set=True + ) + self.RoleSyncer = self.role_syncer_patcher.start() + self.UserSyncer = self.user_syncer_patcher.start() + + self.cog = sync.Sync(self.bot) + + def tearDown(self): + self.role_syncer_patcher.stop() + self.user_syncer_patcher.stop() + + @staticmethod + def response_error(status: int) -> ResponseCodeError: + """Fixture to return a ResponseCodeError with the given status code.""" + response = mock.MagicMock() + response.status = status + + return ResponseCodeError(response) + + +class SyncCogTests(SyncCogTestCase): + """Tests for the Sync cog.""" + + @mock.patch.object(sync.Sync, "sync_guild", new_callable=mock.MagicMock) + def test_sync_cog_init(self, sync_guild): + """Should instantiate syncers and run a sync for the guild.""" + # Reset because a Sync cog was already instantiated in setUp. + self.RoleSyncer.reset_mock() + self.UserSyncer.reset_mock() + self.bot.loop.create_task = mock.MagicMock() + + mock_sync_guild_coro = mock.MagicMock() + sync_guild.return_value = mock_sync_guild_coro + + sync.Sync(self.bot) + + self.RoleSyncer.assert_called_once_with(self.bot) + self.UserSyncer.assert_called_once_with(self.bot) + sync_guild.assert_called_once_with() + self.bot.loop.create_task.assert_called_once_with(mock_sync_guild_coro) + + async def test_sync_cog_sync_guild(self): + """Roles and users should be synced only if a guild is successfully retrieved.""" + for guild in (helpers.MockGuild(), None): + with self.subTest(guild=guild): + self.bot.reset_mock() + self.cog.role_syncer.reset_mock() + self.cog.user_syncer.reset_mock() + + self.bot.get_guild = mock.MagicMock(return_value=guild) + + await self.cog.sync_guild() + + self.bot.wait_until_guild_available.assert_called_once() + self.bot.get_guild.assert_called_once_with(constants.Guild.id) + + if guild is None: + self.cog.role_syncer.sync.assert_not_called() + self.cog.user_syncer.sync.assert_not_called() + else: + self.cog.role_syncer.sync.assert_called_once_with(guild) + self.cog.user_syncer.sync.assert_called_once_with(guild) + + async def patch_user_helper(self, side_effect: BaseException) -> None: + """Helper to set a side effect for bot.api_client.patch and then assert it is called.""" + self.bot.api_client.patch.reset_mock(side_effect=True) + self.bot.api_client.patch.side_effect = side_effect + + user_id, updated_information = 5, {"key": 123} + await self.cog.patch_user(user_id, updated_information) + + self.bot.api_client.patch.assert_called_once_with( + f"bot/users/{user_id}", + json=updated_information, + ) + + async def test_sync_cog_patch_user(self): + """A PATCH request should be sent and 404 errors ignored.""" + for side_effect in (None, self.response_error(404)): + with self.subTest(side_effect=side_effect): + await self.patch_user_helper(side_effect) + + async def test_sync_cog_patch_user_non_404(self): + """A PATCH request should be sent and the error raised if it's not a 404.""" + with self.assertRaises(ResponseCodeError): + await self.patch_user_helper(self.response_error(500)) + + +class SyncCogListenerTests(SyncCogTestCase): + """Tests for the listeners of the Sync cog.""" + + def setUp(self): + super().setUp() + self.cog.patch_user = mock.AsyncMock(spec_set=self.cog.patch_user) + + self.guild_id_patcher = mock.patch("bot.cogs.backend.sync.cog.constants.Guild.id", 5) + self.guild_id = self.guild_id_patcher.start() + + self.guild = helpers.MockGuild(id=self.guild_id) + self.other_guild = helpers.MockGuild(id=0) + + def tearDown(self): + self.guild_id_patcher.stop() + + async def test_sync_cog_on_guild_role_create(self): + """A POST request should be sent with the new role's data.""" + self.assertTrue(self.cog.on_guild_role_create.__cog_listener__) + + role_data = { + "colour": 49, + "id": 777, + "name": "rolename", + "permissions": 8, + "position": 23, + } + role = helpers.MockRole(**role_data, guild=self.guild) + await self.cog.on_guild_role_create(role) + + self.bot.api_client.post.assert_called_once_with("bot/roles", json=role_data) + + async def test_sync_cog_on_guild_role_create_ignores_guilds(self): + """Events from other guilds should be ignored.""" + role = helpers.MockRole(guild=self.other_guild) + await self.cog.on_guild_role_create(role) + self.bot.api_client.post.assert_not_awaited() + + async def test_sync_cog_on_guild_role_delete(self): + """A DELETE request should be sent.""" + self.assertTrue(self.cog.on_guild_role_delete.__cog_listener__) + + role = helpers.MockRole(id=99, guild=self.guild) + await self.cog.on_guild_role_delete(role) + + self.bot.api_client.delete.assert_called_once_with("bot/roles/99") + + async def test_sync_cog_on_guild_role_delete_ignores_guilds(self): + """Events from other guilds should be ignored.""" + role = helpers.MockRole(guild=self.other_guild) + await self.cog.on_guild_role_delete(role) + self.bot.api_client.delete.assert_not_awaited() + + async def test_sync_cog_on_guild_role_update(self): + """A PUT request should be sent if the colour, name, permissions, or position changes.""" + self.assertTrue(self.cog.on_guild_role_update.__cog_listener__) + + role_data = { + "colour": 49, + "id": 777, + "name": "rolename", + "permissions": 8, + "position": 23, + } + subtests = ( + (True, ("colour", "name", "permissions", "position")), + (False, ("hoist", "mentionable")), + ) + + for should_put, attributes in subtests: + for attribute in attributes: + with self.subTest(should_put=should_put, changed_attribute=attribute): + self.bot.api_client.put.reset_mock() + + after_role_data = role_data.copy() + after_role_data[attribute] = 876 + + before_role = helpers.MockRole(**role_data, guild=self.guild) + after_role = helpers.MockRole(**after_role_data, guild=self.guild) + + await self.cog.on_guild_role_update(before_role, after_role) + + if should_put: + self.bot.api_client.put.assert_called_once_with( + f"bot/roles/{after_role.id}", + json=after_role_data + ) + else: + self.bot.api_client.put.assert_not_called() + + async def test_sync_cog_on_guild_role_update_ignores_guilds(self): + """Events from other guilds should be ignored.""" + role = helpers.MockRole(guild=self.other_guild) + await self.cog.on_guild_role_update(role, role) + self.bot.api_client.put.assert_not_awaited() + + async def test_sync_cog_on_member_remove(self): + """Member should be patched to set in_guild as False.""" + self.assertTrue(self.cog.on_member_remove.__cog_listener__) + + member = helpers.MockMember(guild=self.guild) + await self.cog.on_member_remove(member) + + self.cog.patch_user.assert_called_once_with( + member.id, + json={"in_guild": False} + ) + + async def test_sync_cog_on_member_remove_ignores_guilds(self): + """Events from other guilds should be ignored.""" + member = helpers.MockMember(guild=self.other_guild) + await self.cog.on_member_remove(member) + self.cog.patch_user.assert_not_awaited() + + async def test_sync_cog_on_member_update_roles(self): + """Members should be patched if their roles have changed.""" + self.assertTrue(self.cog.on_member_update.__cog_listener__) + + # Roles are intentionally unsorted. + before_roles = [helpers.MockRole(id=12), helpers.MockRole(id=30), helpers.MockRole(id=20)] + before_member = helpers.MockMember(roles=before_roles, guild=self.guild) + after_member = helpers.MockMember(roles=before_roles[1:], guild=self.guild) + + await self.cog.on_member_update(before_member, after_member) + + data = {"roles": sorted(role.id for role in after_member.roles)} + self.cog.patch_user.assert_called_once_with(after_member.id, json=data) + + async def test_sync_cog_on_member_update_other(self): + """Members should not be patched if other attributes have changed.""" + self.assertTrue(self.cog.on_member_update.__cog_listener__) + + subtests = ( + ("activities", discord.Game("Pong"), discord.Game("Frogger")), + ("nick", "old nick", "new nick"), + ("status", discord.Status.online, discord.Status.offline), + ) + + for attribute, old_value, new_value in subtests: + with self.subTest(attribute=attribute): + self.cog.patch_user.reset_mock() + + before_member = helpers.MockMember(**{attribute: old_value}, guild=self.guild) + after_member = helpers.MockMember(**{attribute: new_value}, guild=self.guild) + + await self.cog.on_member_update(before_member, after_member) + + self.cog.patch_user.assert_not_called() + + async def test_sync_cog_on_member_update_ignores_guilds(self): + """Events from other guilds should be ignored.""" + member = helpers.MockMember(guild=self.other_guild) + await self.cog.on_member_update(member, member) + self.cog.patch_user.assert_not_awaited() + + async def test_sync_cog_on_user_update(self): + """A user should be patched only if the name, discriminator, or avatar changes.""" + self.assertTrue(self.cog.on_user_update.__cog_listener__) + + before_data = { + "name": "old name", + "discriminator": "1234", + "bot": False, + } + + subtests = ( + (True, "name", "name", "new name", "new name"), + (True, "discriminator", "discriminator", "8765", 8765), + (False, "bot", "bot", True, True), + ) + + for should_patch, attribute, api_field, value, api_value in subtests: + with self.subTest(attribute=attribute): + self.cog.patch_user.reset_mock() + + after_data = before_data.copy() + after_data[attribute] = value + before_user = helpers.MockUser(**before_data) + after_user = helpers.MockUser(**after_data) + + await self.cog.on_user_update(before_user, after_user) + + if should_patch: + self.cog.patch_user.assert_called_once() + + # Don't care if *all* keys are present; only the changed one is required + call_args = self.cog.patch_user.call_args + self.assertEqual(call_args.args[0], after_user.id) + self.assertIn("json", call_args.kwargs) + + self.assertIn("ignore_404", call_args.kwargs) + self.assertTrue(call_args.kwargs["ignore_404"]) + + json = call_args.kwargs["json"] + self.assertIn(api_field, json) + self.assertEqual(json[api_field], api_value) + else: + self.cog.patch_user.assert_not_called() + + async def on_member_join_helper(self, side_effect: Exception) -> dict: + """ + Helper to set `side_effect` for on_member_join and assert a PUT request was sent. + + The request data for the mock member is returned. All exceptions will be re-raised. + """ + member = helpers.MockMember( + discriminator="1234", + roles=[helpers.MockRole(id=22), helpers.MockRole(id=12)], + guild=self.guild, + ) + + data = { + "discriminator": int(member.discriminator), + "id": member.id, + "in_guild": True, + "name": member.name, + "roles": sorted(role.id for role in member.roles) + } + + self.bot.api_client.put.reset_mock(side_effect=True) + self.bot.api_client.put.side_effect = side_effect + + try: + await self.cog.on_member_join(member) + except Exception: + raise + finally: + self.bot.api_client.put.assert_called_once_with( + f"bot/users/{member.id}", + json=data + ) + + return data + + async def test_sync_cog_on_member_join(self): + """Should PUT user's data or POST it if the user doesn't exist.""" + for side_effect in (None, self.response_error(404)): + with self.subTest(side_effect=side_effect): + self.bot.api_client.post.reset_mock() + data = await self.on_member_join_helper(side_effect) + + if side_effect: + self.bot.api_client.post.assert_called_once_with("bot/users", json=data) + else: + self.bot.api_client.post.assert_not_called() + + async def test_sync_cog_on_member_join_non_404(self): + """ResponseCodeError should be re-raised if status code isn't a 404.""" + with self.assertRaises(ResponseCodeError): + await self.on_member_join_helper(self.response_error(500)) + + self.bot.api_client.post.assert_not_called() + + async def test_sync_cog_on_member_join_ignores_guilds(self): + """Events from other guilds should be ignored.""" + member = helpers.MockMember(guild=self.other_guild) + await self.cog.on_member_join(member) + self.bot.api_client.post.assert_not_awaited() + self.bot.api_client.put.assert_not_awaited() + + +class SyncCogCommandTests(SyncCogTestCase, CommandTestCase): + """Tests for the commands in the Sync cog.""" + + async def test_sync_roles_command(self): + """sync() should be called on the RoleSyncer.""" + ctx = helpers.MockContext() + await self.cog.sync_roles_command.callback(self.cog, ctx) + + self.cog.role_syncer.sync.assert_called_once_with(ctx.guild, ctx) + + async def test_sync_users_command(self): + """sync() should be called on the UserSyncer.""" + ctx = helpers.MockContext() + await self.cog.sync_users_command.callback(self.cog, ctx) + + self.cog.user_syncer.sync.assert_called_once_with(ctx.guild, ctx) + + async def test_commands_require_admin(self): + """The sync commands should only run if the author has the administrator permission.""" + cmds = ( + self.cog.sync_group, + self.cog.sync_roles_command, + self.cog.sync_users_command, + ) + + for cmd in cmds: + with self.subTest(cmd=cmd): + await self.assertHasPermissionsCheck(cmd, {"administrator": True}) diff --git a/tests/bot/cogs/backend/sync/test_roles.py b/tests/bot/cogs/backend/sync/test_roles.py new file mode 100644 index 000000000..cc2e51c7f --- /dev/null +++ b/tests/bot/cogs/backend/sync/test_roles.py @@ -0,0 +1,157 @@ +import unittest +from unittest import mock + +import discord + +from bot.cogs.backend.sync.syncers import RoleSyncer, _Diff, _Role +from tests import helpers + + +def fake_role(**kwargs): + """Fixture to return a dictionary representing a role with default values set.""" + kwargs.setdefault("id", 9) + kwargs.setdefault("name", "fake role") + kwargs.setdefault("colour", 7) + kwargs.setdefault("permissions", 0) + kwargs.setdefault("position", 55) + + return kwargs + + +class RoleSyncerDiffTests(unittest.IsolatedAsyncioTestCase): + """Tests for determining differences between roles in the DB and roles in the Guild cache.""" + + def setUp(self): + self.bot = helpers.MockBot() + self.syncer = RoleSyncer(self.bot) + + @staticmethod + def get_guild(*roles): + """Fixture to return a guild object with the given roles.""" + guild = helpers.MockGuild() + guild.roles = [] + + for role in roles: + mock_role = helpers.MockRole(**role) + mock_role.colour = discord.Colour(role["colour"]) + mock_role.permissions = discord.Permissions(role["permissions"]) + guild.roles.append(mock_role) + + return guild + + async def test_empty_diff_for_identical_roles(self): + """No differences should be found if the roles in the guild and DB are identical.""" + self.bot.api_client.get.return_value = [fake_role()] + guild = self.get_guild(fake_role()) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = (set(), set(), set()) + + self.assertEqual(actual_diff, expected_diff) + + async def test_diff_for_updated_roles(self): + """Only updated roles should be added to the 'updated' set of the diff.""" + updated_role = fake_role(id=41, name="new") + + self.bot.api_client.get.return_value = [fake_role(id=41, name="old"), fake_role()] + guild = self.get_guild(updated_role, fake_role()) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = (set(), {_Role(**updated_role)}, set()) + + self.assertEqual(actual_diff, expected_diff) + + async def test_diff_for_new_roles(self): + """Only new roles should be added to the 'created' set of the diff.""" + new_role = fake_role(id=41, name="new") + + self.bot.api_client.get.return_value = [fake_role()] + guild = self.get_guild(fake_role(), new_role) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = ({_Role(**new_role)}, set(), set()) + + self.assertEqual(actual_diff, expected_diff) + + async def test_diff_for_deleted_roles(self): + """Only deleted roles should be added to the 'deleted' set of the diff.""" + deleted_role = fake_role(id=61, name="deleted") + + self.bot.api_client.get.return_value = [fake_role(), deleted_role] + guild = self.get_guild(fake_role()) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = (set(), set(), {_Role(**deleted_role)}) + + self.assertEqual(actual_diff, expected_diff) + + async def test_diff_for_new_updated_and_deleted_roles(self): + """When roles are added, updated, and removed, all of them are returned properly.""" + new = fake_role(id=41, name="new") + updated = fake_role(id=71, name="updated") + deleted = fake_role(id=61, name="deleted") + + self.bot.api_client.get.return_value = [ + fake_role(), + fake_role(id=71, name="updated name"), + deleted, + ] + guild = self.get_guild(fake_role(), new, updated) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = ({_Role(**new)}, {_Role(**updated)}, {_Role(**deleted)}) + + self.assertEqual(actual_diff, expected_diff) + + +class RoleSyncerSyncTests(unittest.IsolatedAsyncioTestCase): + """Tests for the API requests that sync roles.""" + + def setUp(self): + self.bot = helpers.MockBot() + self.syncer = RoleSyncer(self.bot) + + async def test_sync_created_roles(self): + """Only POST requests should be made with the correct payload.""" + roles = [fake_role(id=111), fake_role(id=222)] + + role_tuples = {_Role(**role) for role in roles} + diff = _Diff(role_tuples, set(), set()) + await self.syncer._sync(diff) + + calls = [mock.call("bot/roles", json=role) for role in roles] + self.bot.api_client.post.assert_has_calls(calls, any_order=True) + self.assertEqual(self.bot.api_client.post.call_count, len(roles)) + + self.bot.api_client.put.assert_not_called() + self.bot.api_client.delete.assert_not_called() + + async def test_sync_updated_roles(self): + """Only PUT requests should be made with the correct payload.""" + roles = [fake_role(id=111), fake_role(id=222)] + + role_tuples = {_Role(**role) for role in roles} + diff = _Diff(set(), role_tuples, set()) + await self.syncer._sync(diff) + + calls = [mock.call(f"bot/roles/{role['id']}", json=role) for role in roles] + self.bot.api_client.put.assert_has_calls(calls, any_order=True) + self.assertEqual(self.bot.api_client.put.call_count, len(roles)) + + self.bot.api_client.post.assert_not_called() + self.bot.api_client.delete.assert_not_called() + + async def test_sync_deleted_roles(self): + """Only DELETE requests should be made with the correct payload.""" + roles = [fake_role(id=111), fake_role(id=222)] + + role_tuples = {_Role(**role) for role in roles} + diff = _Diff(set(), set(), role_tuples) + await self.syncer._sync(diff) + + calls = [mock.call(f"bot/roles/{role['id']}") for role in roles] + self.bot.api_client.delete.assert_has_calls(calls, any_order=True) + self.assertEqual(self.bot.api_client.delete.call_count, len(roles)) + + self.bot.api_client.post.assert_not_called() + self.bot.api_client.put.assert_not_called() diff --git a/tests/bot/cogs/backend/sync/test_users.py b/tests/bot/cogs/backend/sync/test_users.py new file mode 100644 index 000000000..490ea9e06 --- /dev/null +++ b/tests/bot/cogs/backend/sync/test_users.py @@ -0,0 +1,158 @@ +import unittest +from unittest import mock + +from bot.cogs.backend.sync.syncers import UserSyncer, _Diff, _User +from tests import helpers + + +def fake_user(**kwargs): + """Fixture to return a dictionary representing a user with default values set.""" + kwargs.setdefault("id", 43) + kwargs.setdefault("name", "bob the test man") + kwargs.setdefault("discriminator", 1337) + kwargs.setdefault("roles", (666,)) + kwargs.setdefault("in_guild", True) + + return kwargs + + +class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): + """Tests for determining differences between users in the DB and users in the Guild cache.""" + + def setUp(self): + self.bot = helpers.MockBot() + self.syncer = UserSyncer(self.bot) + + @staticmethod + def get_guild(*members): + """Fixture to return a guild object with the given members.""" + guild = helpers.MockGuild() + guild.members = [] + + for member in members: + member = member.copy() + del member["in_guild"] + + mock_member = helpers.MockMember(**member) + mock_member.roles = [helpers.MockRole(id=role_id) for role_id in member["roles"]] + + guild.members.append(mock_member) + + return guild + + async def test_empty_diff_for_no_users(self): + """When no users are given, an empty diff should be returned.""" + guild = self.get_guild() + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = (set(), set(), None) + + self.assertEqual(actual_diff, expected_diff) + + async def test_empty_diff_for_identical_users(self): + """No differences should be found if the users in the guild and DB are identical.""" + self.bot.api_client.get.return_value = [fake_user()] + guild = self.get_guild(fake_user()) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = (set(), set(), None) + + self.assertEqual(actual_diff, expected_diff) + + async def test_diff_for_updated_users(self): + """Only updated users should be added to the 'updated' set of the diff.""" + updated_user = fake_user(id=99, name="new") + + self.bot.api_client.get.return_value = [fake_user(id=99, name="old"), fake_user()] + guild = self.get_guild(updated_user, fake_user()) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = (set(), {_User(**updated_user)}, None) + + self.assertEqual(actual_diff, expected_diff) + + async def test_diff_for_new_users(self): + """Only new users should be added to the 'created' set of the diff.""" + new_user = fake_user(id=99, name="new") + + self.bot.api_client.get.return_value = [fake_user()] + guild = self.get_guild(fake_user(), new_user) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = ({_User(**new_user)}, set(), None) + + self.assertEqual(actual_diff, expected_diff) + + async def test_diff_sets_in_guild_false_for_leaving_users(self): + """When a user leaves the guild, the `in_guild` flag is updated to `False`.""" + leaving_user = fake_user(id=63, in_guild=False) + + self.bot.api_client.get.return_value = [fake_user(), fake_user(id=63)] + guild = self.get_guild(fake_user()) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = (set(), {_User(**leaving_user)}, None) + + self.assertEqual(actual_diff, expected_diff) + + async def test_diff_for_new_updated_and_leaving_users(self): + """When users are added, updated, and removed, all of them are returned properly.""" + new_user = fake_user(id=99, name="new") + updated_user = fake_user(id=55, name="updated") + leaving_user = fake_user(id=63, in_guild=False) + + self.bot.api_client.get.return_value = [fake_user(), fake_user(id=55), fake_user(id=63)] + guild = self.get_guild(fake_user(), new_user, updated_user) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = ({_User(**new_user)}, {_User(**updated_user), _User(**leaving_user)}, None) + + self.assertEqual(actual_diff, expected_diff) + + async def test_empty_diff_for_db_users_not_in_guild(self): + """When the DB knows a user the guild doesn't, no difference is found.""" + self.bot.api_client.get.return_value = [fake_user(), fake_user(id=63, in_guild=False)] + guild = self.get_guild(fake_user()) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = (set(), set(), None) + + self.assertEqual(actual_diff, expected_diff) + + +class UserSyncerSyncTests(unittest.IsolatedAsyncioTestCase): + """Tests for the API requests that sync users.""" + + def setUp(self): + self.bot = helpers.MockBot() + self.syncer = UserSyncer(self.bot) + + async def test_sync_created_users(self): + """Only POST requests should be made with the correct payload.""" + users = [fake_user(id=111), fake_user(id=222)] + + user_tuples = {_User(**user) for user in users} + diff = _Diff(user_tuples, set(), None) + await self.syncer._sync(diff) + + calls = [mock.call("bot/users", json=user) for user in users] + self.bot.api_client.post.assert_has_calls(calls, any_order=True) + self.assertEqual(self.bot.api_client.post.call_count, len(users)) + + self.bot.api_client.put.assert_not_called() + self.bot.api_client.delete.assert_not_called() + + async def test_sync_updated_users(self): + """Only PUT requests should be made with the correct payload.""" + users = [fake_user(id=111), fake_user(id=222)] + + user_tuples = {_User(**user) for user in users} + diff = _Diff(set(), user_tuples, None) + await self.syncer._sync(diff) + + calls = [mock.call(f"bot/users/{user['id']}", json=user) for user in users] + self.bot.api_client.put.assert_has_calls(calls, any_order=True) + self.assertEqual(self.bot.api_client.put.call_count, len(users)) + + self.bot.api_client.post.assert_not_called() + self.bot.api_client.delete.assert_not_called() diff --git a/tests/bot/cogs/backend/test_logging.py b/tests/bot/cogs/backend/test_logging.py new file mode 100644 index 000000000..c867773e2 --- /dev/null +++ b/tests/bot/cogs/backend/test_logging.py @@ -0,0 +1,32 @@ +import unittest +from unittest.mock import patch + +from bot import constants +from bot.cogs.backend.logging import Logging +from tests.helpers import MockBot, MockTextChannel + + +class LoggingTests(unittest.IsolatedAsyncioTestCase): + """Test cases for connected login.""" + + def setUp(self): + self.bot = MockBot() + self.cog = Logging(self.bot) + self.dev_log = MockTextChannel(id=1234, name="dev-log") + + @patch("bot.cogs.backend.logging.DEBUG_MODE", False) + async def test_debug_mode_false(self): + """Should send connected message to dev-log.""" + self.bot.get_channel.return_value = self.dev_log + + await self.cog.startup_greeting() + self.bot.wait_until_guild_available.assert_awaited_once_with() + self.bot.get_channel.assert_called_once_with(constants.Channels.dev_log) + self.dev_log.send.assert_awaited_once() + + @patch("bot.cogs.backend.logging.DEBUG_MODE", True) + async def test_debug_mode_true(self): + """Should not send anything to dev-log.""" + await self.cog.startup_greeting() + self.bot.wait_until_guild_available.assert_awaited_once_with() + self.bot.get_channel.assert_not_called() diff --git a/tests/bot/cogs/filters/__init__.py b/tests/bot/cogs/filters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/cogs/filters/test_antimalware.py b/tests/bot/cogs/filters/test_antimalware.py new file mode 100644 index 000000000..b00211f47 --- /dev/null +++ b/tests/bot/cogs/filters/test_antimalware.py @@ -0,0 +1,165 @@ +import unittest +from unittest.mock import AsyncMock, Mock + +from discord import NotFound + +from bot.cogs.filters import antimalware +from bot.constants import Channels, STAFF_ROLES +from tests.helpers import MockAttachment, MockBot, MockMessage, MockRole + + +class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): + """Test the AntiMalware cog.""" + + def setUp(self): + """Sets up fresh objects for each test.""" + self.bot = MockBot() + self.bot.filter_list_cache = { + "FILE_FORMAT.True": { + ".first": {}, + ".second": {}, + ".third": {}, + } + } + self.cog = antimalware.AntiMalware(self.bot) + self.message = MockMessage() + self.whitelist = [".first", ".second", ".third"] + + async def test_message_with_allowed_attachment(self): + """Messages with allowed extensions should not be deleted""" + attachment = MockAttachment(filename="python.first") + self.message.attachments = [attachment] + + await self.cog.on_message(self.message) + self.message.delete.assert_not_called() + + async def test_message_without_attachment(self): + """Messages without attachments should result in no action.""" + await self.cog.on_message(self.message) + self.message.delete.assert_not_called() + + async def test_direct_message_with_attachment(self): + """Direct messages should have no action taken.""" + attachment = MockAttachment(filename="python.disallowed") + self.message.attachments = [attachment] + self.message.guild = None + + await self.cog.on_message(self.message) + + self.message.delete.assert_not_called() + + async def test_message_with_illegal_extension_gets_deleted(self): + """A message containing an illegal extension should send an embed.""" + attachment = MockAttachment(filename="python.disallowed") + self.message.attachments = [attachment] + + await self.cog.on_message(self.message) + + self.message.delete.assert_called_once() + + async def test_message_send_by_staff(self): + """A message send by a member of staff should be ignored.""" + staff_role = MockRole(id=STAFF_ROLES[0]) + self.message.author.roles.append(staff_role) + attachment = MockAttachment(filename="python.disallowed") + self.message.attachments = [attachment] + + await self.cog.on_message(self.message) + + self.message.delete.assert_not_called() + + async def test_python_file_redirect_embed_description(self): + """A message containing a .py file should result in an embed redirecting the user to our paste site""" + attachment = MockAttachment(filename="python.py") + self.message.attachments = [attachment] + self.message.channel.send = AsyncMock() + + await self.cog.on_message(self.message) + self.message.channel.send.assert_called_once() + args, kwargs = self.message.channel.send.call_args + embed = kwargs.pop("embed") + + self.assertEqual(embed.description, antimalware.PY_EMBED_DESCRIPTION) + + async def test_txt_file_redirect_embed_description(self): + """A message containing a .txt file should result in the correct embed.""" + attachment = MockAttachment(filename="python.txt") + self.message.attachments = [attachment] + self.message.channel.send = AsyncMock() + antimalware.TXT_EMBED_DESCRIPTION = Mock() + antimalware.TXT_EMBED_DESCRIPTION.format.return_value = "test" + + await self.cog.on_message(self.message) + self.message.channel.send.assert_called_once() + args, kwargs = self.message.channel.send.call_args + embed = kwargs.pop("embed") + cmd_channel = self.bot.get_channel(Channels.bot_commands) + + self.assertEqual(embed.description, antimalware.TXT_EMBED_DESCRIPTION.format.return_value) + antimalware.TXT_EMBED_DESCRIPTION.format.assert_called_with(cmd_channel_mention=cmd_channel.mention) + + async def test_other_disallowed_extension_embed_description(self): + """Test the description for a non .py/.txt disallowed extension.""" + attachment = MockAttachment(filename="python.disallowed") + self.message.attachments = [attachment] + self.message.channel.send = AsyncMock() + antimalware.DISALLOWED_EMBED_DESCRIPTION = Mock() + antimalware.DISALLOWED_EMBED_DESCRIPTION.format.return_value = "test" + + await self.cog.on_message(self.message) + self.message.channel.send.assert_called_once() + args, kwargs = self.message.channel.send.call_args + embed = kwargs.pop("embed") + meta_channel = self.bot.get_channel(Channels.meta) + + self.assertEqual(embed.description, antimalware.DISALLOWED_EMBED_DESCRIPTION.format.return_value) + antimalware.DISALLOWED_EMBED_DESCRIPTION.format.assert_called_with( + joined_whitelist=", ".join(self.whitelist), + blocked_extensions_str=".disallowed", + meta_channel_mention=meta_channel.mention + ) + + async def test_removing_deleted_message_logs(self): + """Removing an already deleted message logs the correct message""" + attachment = MockAttachment(filename="python.disallowed") + self.message.attachments = [attachment] + self.message.delete = AsyncMock(side_effect=NotFound(response=Mock(status=""), message="")) + + with self.assertLogs(logger=antimalware.log, level="INFO"): + await self.cog.on_message(self.message) + self.message.delete.assert_called_once() + + async def test_message_with_illegal_attachment_logs(self): + """Deleting a message with an illegal attachment should result in a log.""" + attachment = MockAttachment(filename="python.disallowed") + self.message.attachments = [attachment] + + with self.assertLogs(logger=antimalware.log, level="INFO"): + await self.cog.on_message(self.message) + + async def test_get_disallowed_extensions(self): + """The return value should include all non-whitelisted extensions.""" + test_values = ( + ([], []), + (self.whitelist, []), + ([".first"], []), + ([".first", ".disallowed"], [".disallowed"]), + ([".disallowed"], [".disallowed"]), + ([".disallowed", ".illegal"], [".disallowed", ".illegal"]), + ) + + for extensions, expected_disallowed_extensions in test_values: + with self.subTest(extensions=extensions, expected_disallowed_extensions=expected_disallowed_extensions): + self.message.attachments = [MockAttachment(filename=f"filename{extension}") for extension in extensions] + disallowed_extensions = self.cog._get_disallowed_extensions(self.message) + self.assertCountEqual(disallowed_extensions, expected_disallowed_extensions) + + +class AntiMalwareSetupTests(unittest.TestCase): + """Tests setup of the `AntiMalware` cog.""" + + def test_setup(self): + """Setup of the extension should call add_cog.""" + bot = MockBot() + antimalware.setup(bot) + bot.add_cog.assert_called_once() diff --git a/tests/bot/cogs/filters/test_antispam.py b/tests/bot/cogs/filters/test_antispam.py new file mode 100644 index 000000000..8a3d8d02e --- /dev/null +++ b/tests/bot/cogs/filters/test_antispam.py @@ -0,0 +1,35 @@ +import unittest + +from bot.cogs.filters import antispam + + +class AntispamConfigurationValidationTests(unittest.TestCase): + """Tests validation of the antispam cog configuration.""" + + def test_default_antispam_config_is_valid(self): + """The default antispam configuration is valid.""" + validation_errors = antispam.validate_config() + self.assertEqual(validation_errors, {}) + + def test_unknown_rule_returns_error(self): + """Configuring an unknown rule returns an error.""" + self.assertEqual( + antispam.validate_config({'invalid-rule': {}}), + {'invalid-rule': "`invalid-rule` is not recognized as an antispam rule."} + ) + + def test_missing_keys_returns_error(self): + """Not configuring required keys returns an error.""" + keys = (('interval', 'max'), ('max', 'interval')) + for configured_key, unconfigured_key in keys: + with self.subTest( + configured_key=configured_key, + unconfigured_key=unconfigured_key + ): + config = {'burst': {configured_key: 10}} + error = f"Key `{unconfigured_key}` is required but not set for rule `burst`" + + self.assertEqual( + antispam.validate_config(config), + {'burst': error} + ) diff --git a/tests/bot/cogs/filters/test_security.py b/tests/bot/cogs/filters/test_security.py new file mode 100644 index 000000000..82679f69c --- /dev/null +++ b/tests/bot/cogs/filters/test_security.py @@ -0,0 +1,54 @@ +import unittest +from unittest.mock import MagicMock + +from discord.ext.commands import NoPrivateMessage + +from bot.cogs.filters import security +from tests.helpers import MockBot, MockContext + + +class SecurityCogTests(unittest.TestCase): + """Tests the `Security` cog.""" + + def setUp(self): + """Attach an instance of the cog to the class for tests.""" + self.bot = MockBot() + self.cog = security.Security(self.bot) + self.ctx = MockContext() + + def test_check_additions(self): + """The cog should add its checks after initialization.""" + self.bot.check.assert_any_call(self.cog.check_on_guild) + self.bot.check.assert_any_call(self.cog.check_not_bot) + + def test_check_not_bot_returns_false_for_humans(self): + """The bot check should return `True` when invoked with human authors.""" + self.ctx.author.bot = False + self.assertTrue(self.cog.check_not_bot(self.ctx)) + + def test_check_not_bot_returns_true_for_robots(self): + """The bot check should return `False` when invoked with robotic authors.""" + self.ctx.author.bot = True + self.assertFalse(self.cog.check_not_bot(self.ctx)) + + def test_check_on_guild_raises_when_outside_of_guild(self): + """When invoked outside of a guild, `check_on_guild` should cause an error.""" + self.ctx.guild = None + + with self.assertRaises(NoPrivateMessage, msg="This command cannot be used in private messages."): + self.cog.check_on_guild(self.ctx) + + def test_check_on_guild_returns_true_inside_of_guild(self): + """When invoked inside of a guild, `check_on_guild` should return `True`.""" + self.ctx.guild = "lemon's lemonade stand" + self.assertTrue(self.cog.check_on_guild(self.ctx)) + + +class SecurityCogLoadTests(unittest.TestCase): + """Tests loading the `Security` cog.""" + + def test_security_cog_load(self): + """Setup of the extension should call add_cog.""" + bot = MagicMock() + security.setup(bot) + bot.add_cog.assert_called_once() diff --git a/tests/bot/cogs/filters/test_token_remover.py b/tests/bot/cogs/filters/test_token_remover.py new file mode 100644 index 000000000..5c527ed94 --- /dev/null +++ b/tests/bot/cogs/filters/test_token_remover.py @@ -0,0 +1,310 @@ +import unittest +from re import Match +from unittest import mock +from unittest.mock import MagicMock + +from discord import Colour, NotFound + +from bot import constants +from bot.cogs.filters import token_remover +from bot.cogs.filters.token_remover import Token, TokenRemover +from bot.cogs.moderation import ModLog +from tests.helpers import MockBot, MockMessage, autospec + + +class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): + """Tests the `TokenRemover` cog.""" + + def setUp(self): + """Adds the cog, a bot, and a message to the instance for usage in tests.""" + self.bot = MockBot() + self.cog = TokenRemover(bot=self.bot) + + self.msg = MockMessage(id=555, content="hello world") + self.msg.channel.mention = "#lemonade-stand" + self.msg.author.__str__ = MagicMock(return_value=self.msg.author.name) + self.msg.author.avatar_url_as.return_value = "picture-lemon.png" + + def test_is_valid_user_id_valid(self): + """Should consider user IDs valid if they decode entirely to ASCII digits.""" + ids = ( + "NDcyMjY1OTQzMDYyNDEzMzMy", + "NDc1MDczNjI5Mzk5NTQ3OTA0", + "NDY3MjIzMjMwNjUwNzc3NjQx", + ) + + for user_id in ids: + with self.subTest(user_id=user_id): + result = TokenRemover.is_valid_user_id(user_id) + self.assertTrue(result) + + def test_is_valid_user_id_invalid(self): + """Should consider non-digit and non-ASCII IDs invalid.""" + ids = ( + ("SGVsbG8gd29ybGQ", "non-digit ASCII"), + ("0J_RgNC40LLQtdGCINC80LjRgA", "cyrillic text"), + ("4pO14p6L4p6C4pG34p264pGl8J-EiOKSj-KCieKBsA", "Unicode digits"), + ("4oaA4oaB4oWh4oWi4Lyz4Lyq4Lyr4LG9", "Unicode numerals"), + ("8J2fjvCdn5nwnZ-k8J2fr_Cdn7rgravvvJngr6c", "Unicode decimals"), + ("{hello}[world]&(bye!)", "ASCII invalid Base64"), + ("Þíß-ï§-ňøẗ-våłìÐ", "Unicode invalid Base64"), + ) + + for user_id, msg in ids: + with self.subTest(msg=msg): + result = TokenRemover.is_valid_user_id(user_id) + self.assertFalse(result) + + def test_is_valid_timestamp_valid(self): + """Should consider timestamps valid if they're greater than the Discord epoch.""" + timestamps = ( + "XsyRkw", + "Xrim9Q", + "XsyR-w", + "XsySD_", + "Dn9r_A", + ) + + for timestamp in timestamps: + with self.subTest(timestamp=timestamp): + result = TokenRemover.is_valid_timestamp(timestamp) + self.assertTrue(result) + + def test_is_valid_timestamp_invalid(self): + """Should consider timestamps invalid if they're before Discord epoch or can't be parsed.""" + timestamps = ( + ("B4Yffw", "DISCORD_EPOCH - TOKEN_EPOCH - 1"), + ("ew", "123"), + ("AoIKgA", "42076800"), + ("{hello}[world]&(bye!)", "ASCII invalid Base64"), + ("Þíß-ï§-ňøẗ-våłìÐ", "Unicode invalid Base64"), + ) + + for timestamp, msg in timestamps: + with self.subTest(msg=msg): + result = TokenRemover.is_valid_timestamp(timestamp) + self.assertFalse(result) + + def test_mod_log_property(self): + """The `mod_log` property should ask the bot to return the `ModLog` cog.""" + self.bot.get_cog.return_value = 'lemon' + self.assertEqual(self.cog.mod_log, self.bot.get_cog.return_value) + self.bot.get_cog.assert_called_once_with('ModLog') + + async def test_on_message_edit_uses_on_message(self): + """The edit listener should delegate handling of the message to the normal listener.""" + self.cog.on_message = mock.create_autospec(self.cog.on_message, spec_set=True) + + await self.cog.on_message_edit(MockMessage(), self.msg) + self.cog.on_message.assert_awaited_once_with(self.msg) + + @autospec(TokenRemover, "find_token_in_message", "take_action") + async def test_on_message_takes_action(self, find_token_in_message, take_action): + """Should take action if a valid token is found when a message is sent.""" + cog = TokenRemover(self.bot) + found_token = "foobar" + find_token_in_message.return_value = found_token + + await cog.on_message(self.msg) + + find_token_in_message.assert_called_once_with(self.msg) + take_action.assert_awaited_once_with(cog, self.msg, found_token) + + @autospec(TokenRemover, "find_token_in_message", "take_action") + async def test_on_message_skips_missing_token(self, find_token_in_message, take_action): + """Shouldn't take action if a valid token isn't found when a message is sent.""" + cog = TokenRemover(self.bot) + find_token_in_message.return_value = False + + await cog.on_message(self.msg) + + find_token_in_message.assert_called_once_with(self.msg) + take_action.assert_not_awaited() + + @autospec(TokenRemover, "find_token_in_message") + async def test_on_message_ignores_dms_bots(self, find_token_in_message): + """Shouldn't parse a message if it is a DM or authored by a bot.""" + cog = TokenRemover(self.bot) + dm_msg = MockMessage(guild=None) + bot_msg = MockMessage(author=MagicMock(bot=True)) + + for msg in (dm_msg, bot_msg): + await cog.on_message(msg) + find_token_in_message.assert_not_called() + + @autospec("bot.cogs.filters.token_remover", "TOKEN_RE") + def test_find_token_no_matches(self, token_re): + """None should be returned if the regex matches no tokens in a message.""" + token_re.finditer.return_value = () + + return_value = TokenRemover.find_token_in_message(self.msg) + + self.assertIsNone(return_value) + token_re.finditer.assert_called_once_with(self.msg.content) + + @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") + @autospec("bot.cogs.filters.token_remover", "Token") + @autospec("bot.cogs.filters.token_remover", "TOKEN_RE") + def test_find_token_valid_match(self, token_re, token_cls, is_valid_id, is_valid_timestamp): + """The first match with a valid user ID and timestamp should be returned as a `Token`.""" + matches = [ + mock.create_autospec(Match, spec_set=True, instance=True), + mock.create_autospec(Match, spec_set=True, instance=True), + ] + tokens = [ + mock.create_autospec(Token, spec_set=True, instance=True), + mock.create_autospec(Token, spec_set=True, instance=True), + ] + + token_re.finditer.return_value = matches + token_cls.side_effect = tokens + is_valid_id.side_effect = (False, True) # The 1st match will be invalid, 2nd one valid. + is_valid_timestamp.return_value = True + + return_value = TokenRemover.find_token_in_message(self.msg) + + self.assertEqual(tokens[1], return_value) + token_re.finditer.assert_called_once_with(self.msg.content) + + @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") + @autospec("bot.cogs.filters.token_remover", "Token") + @autospec("bot.cogs.filters.token_remover", "TOKEN_RE") + def test_find_token_invalid_matches(self, token_re, token_cls, is_valid_id, is_valid_timestamp): + """None should be returned if no matches have valid user IDs or timestamps.""" + token_re.finditer.return_value = [mock.create_autospec(Match, spec_set=True, instance=True)] + token_cls.return_value = mock.create_autospec(Token, spec_set=True, instance=True) + is_valid_id.return_value = False + is_valid_timestamp.return_value = False + + return_value = TokenRemover.find_token_in_message(self.msg) + + self.assertIsNone(return_value) + token_re.finditer.assert_called_once_with(self.msg.content) + + def test_regex_invalid_tokens(self): + """Messages without anything looking like a token are not matched.""" + tokens = ( + "", + "lemon wins", + "..", + "x.y", + "x.y.", + ".y.z", + ".y.", + "..z", + "x..z", + " . . ", + "\n.\n.\n", + "hellö.world.bye", + "base64.nötbåse64.morebase64", + "19jd3J.dfkm3d.€víł§tüff", + ) + + for token in tokens: + with self.subTest(token=token): + results = token_remover.TOKEN_RE.findall(token) + self.assertEqual(len(results), 0) + + def test_regex_valid_tokens(self): + """Messages that look like tokens should be matched.""" + # Don't worry, these tokens have been invalidated. + tokens = ( + "NDcyMjY1OTQzMDYy_DEzMz-y.XsyRkw.VXmErH7j511turNpfURmb0rVNm8", + "NDcyMjY1OTQzMDYyNDEzMzMy.Xrim9Q.Ysnu2wacjaKs7qnoo46S8Dm2us8", + "NDc1MDczNjI5Mzk5NTQ3OTA0.XsyR-w.sJf6omBPORBPju3WJEIAcwW9Zds", + "NDY3MjIzMjMwNjUwNzc3NjQx.XsySD_.s45jqDV_Iisn-symw0yDRrk_jf4", + ) + + for token in tokens: + with self.subTest(token=token): + results = token_remover.TOKEN_RE.fullmatch(token) + self.assertIsNotNone(results, f"{token} was not matched by the regex") + + def test_regex_matches_multiple_valid(self): + """Should support multiple matches in the middle of a string.""" + token_1 = "NDY3MjIzMjMwNjUwNzc3NjQx.XsyWGg.uFNEQPCc4ePwGh7egG8UicQssz8" + token_2 = "NDcyMjY1OTQzMDYyNDEzMzMy.XsyWMw.l8XPnDqb0lp-EiQ2g_0xVFT1pyc" + message = f"garbage {token_1} hello {token_2} world" + + results = token_remover.TOKEN_RE.finditer(message) + results = [match[0] for match in results] + self.assertCountEqual((token_1, token_2), results) + + @autospec("bot.cogs.filters.token_remover", "LOG_MESSAGE") + def test_format_log_message(self, log_message): + """Should correctly format the log message with info from the message and token.""" + token = Token("NDY3MjIzMjMwNjUwNzc3NjQx", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") + log_message.format.return_value = "Howdy" + + return_value = TokenRemover.format_log_message(self.msg, token) + + self.assertEqual(return_value, log_message.format.return_value) + log_message.format.assert_called_once_with( + author=self.msg.author, + author_id=self.msg.author.id, + channel=self.msg.channel.mention, + user_id=token.user_id, + timestamp=token.timestamp, + hmac="x" * len(token.hmac), + ) + + @mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock) + @autospec("bot.cogs.filters.token_remover", "log") + @autospec(TokenRemover, "format_log_message") + async def test_take_action(self, format_log_message, logger, mod_log_property): + """Should delete the message and send a mod log.""" + cog = TokenRemover(self.bot) + mod_log = mock.create_autospec(ModLog, spec_set=True, instance=True) + token = mock.create_autospec(Token, spec_set=True, instance=True) + log_msg = "testing123" + + mod_log_property.return_value = mod_log + format_log_message.return_value = log_msg + + await cog.take_action(self.msg, token) + + self.msg.delete.assert_called_once_with() + self.msg.channel.send.assert_called_once_with( + token_remover.DELETION_MESSAGE_TEMPLATE.format(mention=self.msg.author.mention) + ) + + format_log_message.assert_called_once_with(self.msg, token) + logger.debug.assert_called_with(log_msg) + self.bot.stats.incr.assert_called_once_with("tokens.removed_tokens") + + mod_log.ignore.assert_called_once_with(constants.Event.message_delete, self.msg.id) + mod_log.send_log_message.assert_called_once_with( + icon_url=constants.Icons.token_removed, + colour=Colour(constants.Colours.soft_red), + title="Token removed!", + text=log_msg, + thumbnail=self.msg.author.avatar_url_as.return_value, + channel_id=constants.Channels.mod_alerts + ) + + @mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock) + async def test_take_action_delete_failure(self, mod_log_property): + """Shouldn't send any messages if the token message can't be deleted.""" + cog = TokenRemover(self.bot) + mod_log_property.return_value = mock.create_autospec(ModLog, spec_set=True, instance=True) + self.msg.delete.side_effect = NotFound(MagicMock(), MagicMock()) + + token = mock.create_autospec(Token, spec_set=True, instance=True) + await cog.take_action(self.msg, token) + + self.msg.delete.assert_called_once_with() + self.msg.channel.send.assert_not_awaited() + + +class TokenRemoverExtensionTests(unittest.TestCase): + """Tests for the token_remover extension.""" + + @autospec("bot.cogs.filters.token_remover", "TokenRemover") + def test_extension_setup(self, cog): + """The TokenRemover cog should be added.""" + bot = MockBot() + token_remover.setup(bot) + + cog.assert_called_once_with(bot) + bot.add_cog.assert_called_once() + self.assertTrue(isinstance(bot.add_cog.call_args.args[0], TokenRemover)) diff --git a/tests/bot/cogs/info/__init__.py b/tests/bot/cogs/info/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/cogs/info/test_information.py b/tests/bot/cogs/info/test_information.py new file mode 100644 index 000000000..895a8328e --- /dev/null +++ b/tests/bot/cogs/info/test_information.py @@ -0,0 +1,584 @@ +import asyncio +import textwrap +import unittest +import unittest.mock + +import discord + +from bot import constants +from bot.cogs.info import information +from bot.utils.checks import InWhitelistCheckFailure +from tests import helpers + +COG_PATH = "bot.cogs.info.information.Information" + + +class InformationCogTests(unittest.TestCase): + """Tests the Information cog.""" + + @classmethod + def setUpClass(cls): + cls.moderator_role = helpers.MockRole(name="Moderator", id=constants.Roles.moderators) + + def setUp(self): + """Sets up fresh objects for each test.""" + self.bot = helpers.MockBot() + + self.cog = information.Information(self.bot) + + self.ctx = helpers.MockContext() + self.ctx.author.roles.append(self.moderator_role) + + def test_roles_command_command(self): + """Test if the `role_info` command correctly returns the `moderator_role`.""" + self.ctx.guild.roles.append(self.moderator_role) + + self.cog.roles_info.can_run = unittest.mock.AsyncMock() + self.cog.roles_info.can_run.return_value = True + + coroutine = self.cog.roles_info.callback(self.cog, self.ctx) + + self.assertIsNone(asyncio.run(coroutine)) + self.ctx.send.assert_called_once() + + _, kwargs = self.ctx.send.call_args + embed = kwargs.pop('embed') + + self.assertEqual(embed.title, "Role information (Total 1 role)") + self.assertEqual(embed.colour, discord.Colour.blurple()) + self.assertEqual(embed.description, f"\n`{self.moderator_role.id}` - {self.moderator_role.mention}\n") + + def test_role_info_command(self): + """Tests the `role info` command.""" + dummy_role = helpers.MockRole( + name="Dummy", + id=112233445566778899, + colour=discord.Colour.blurple(), + position=10, + members=[self.ctx.author], + permissions=discord.Permissions(0) + ) + + admin_role = helpers.MockRole( + name="Admins", + id=998877665544332211, + colour=discord.Colour.red(), + position=3, + members=[self.ctx.author], + permissions=discord.Permissions(0), + ) + + self.ctx.guild.roles.append([dummy_role, admin_role]) + + self.cog.role_info.can_run = unittest.mock.AsyncMock() + self.cog.role_info.can_run.return_value = True + + coroutine = self.cog.role_info.callback(self.cog, self.ctx, dummy_role, admin_role) + + self.assertIsNone(asyncio.run(coroutine)) + + self.assertEqual(self.ctx.send.call_count, 2) + + (_, dummy_kwargs), (_, admin_kwargs) = self.ctx.send.call_args_list + + dummy_embed = dummy_kwargs["embed"] + admin_embed = admin_kwargs["embed"] + + self.assertEqual(dummy_embed.title, "Dummy info") + self.assertEqual(dummy_embed.colour, discord.Colour.blurple()) + + self.assertEqual(dummy_embed.fields[0].value, str(dummy_role.id)) + self.assertEqual(dummy_embed.fields[1].value, f"#{dummy_role.colour.value:0>6x}") + self.assertEqual(dummy_embed.fields[2].value, "0.63 0.48 218") + self.assertEqual(dummy_embed.fields[3].value, "1") + self.assertEqual(dummy_embed.fields[4].value, "10") + self.assertEqual(dummy_embed.fields[5].value, "0") + + self.assertEqual(admin_embed.title, "Admins info") + self.assertEqual(admin_embed.colour, discord.Colour.red()) + + @unittest.mock.patch('bot.cogs.info.information.time_since') + def test_server_info_command(self, time_since_patch): + time_since_patch.return_value = '2 days ago' + + self.ctx.guild = helpers.MockGuild( + features=('lemons', 'apples'), + region="The Moon", + roles=[self.moderator_role], + channels=[ + discord.TextChannel( + state={}, + guild=self.ctx.guild, + data={'id': 42, 'name': 'lemons-offering', 'position': 22, 'type': 'text'} + ), + discord.CategoryChannel( + state={}, + guild=self.ctx.guild, + data={'id': 5125, 'name': 'the-lemon-collection', 'position': 22, 'type': 'category'} + ), + discord.VoiceChannel( + state={}, + guild=self.ctx.guild, + data={'id': 15290, 'name': 'listen-to-lemon', 'position': 22, 'type': 'voice'} + ) + ], + members=[ + *(helpers.MockMember(status=discord.Status.online) for _ in range(2)), + *(helpers.MockMember(status=discord.Status.idle) for _ in range(1)), + *(helpers.MockMember(status=discord.Status.dnd) for _ in range(4)), + *(helpers.MockMember(status=discord.Status.offline) for _ in range(3)), + ], + member_count=1_234, + icon_url='a-lemon.jpg', + ) + + coroutine = self.cog.server_info.callback(self.cog, self.ctx) + self.assertIsNone(asyncio.run(coroutine)) + + time_since_patch.assert_called_once_with(self.ctx.guild.created_at, precision='days') + _, kwargs = self.ctx.send.call_args + embed = kwargs.pop('embed') + self.assertEqual(embed.colour, discord.Colour.blurple()) + self.assertEqual( + embed.description, + textwrap.dedent( + f""" + **Server information** + Created: {time_since_patch.return_value} + Voice region: {self.ctx.guild.region} + Features: {', '.join(self.ctx.guild.features)} + + **Channel counts** + Category channels: 1 + Text channels: 1 + Voice channels: 1 + Staff channels: 0 + + **Member counts** + Members: {self.ctx.guild.member_count:,} + Staff members: 0 + Roles: {len(self.ctx.guild.roles)} + + **Member statuses** + {constants.Emojis.status_online} 2 + {constants.Emojis.status_idle} 1 + {constants.Emojis.status_dnd} 4 + {constants.Emojis.status_offline} 3 + """ + ) + ) + self.assertEqual(embed.thumbnail.url, 'a-lemon.jpg') + + +class UserInfractionHelperMethodTests(unittest.TestCase): + """Tests for the helper methods of the `!user` command.""" + + def setUp(self): + """Common set-up steps done before for each test.""" + self.bot = helpers.MockBot() + self.bot.api_client.get = unittest.mock.AsyncMock() + self.cog = information.Information(self.bot) + self.member = helpers.MockMember(id=1234) + + def test_user_command_helper_method_get_requests(self): + """The helper methods should form the correct get requests.""" + test_values = ( + { + "helper_method": self.cog.basic_user_infraction_counts, + "expected_args": ("bot/infractions", {'hidden': 'False', 'user__id': str(self.member.id)}), + }, + { + "helper_method": self.cog.expanded_user_infraction_counts, + "expected_args": ("bot/infractions", {'user__id': str(self.member.id)}), + }, + { + "helper_method": self.cog.user_nomination_counts, + "expected_args": ("bot/nominations", {'user__id': str(self.member.id)}), + }, + ) + + for test_value in test_values: + helper_method = test_value["helper_method"] + endpoint, params = test_value["expected_args"] + + with self.subTest(method=helper_method, endpoint=endpoint, params=params): + asyncio.run(helper_method(self.member)) + self.bot.api_client.get.assert_called_once_with(endpoint, params=params) + self.bot.api_client.get.reset_mock() + + def _method_subtests(self, method, test_values, default_header): + """Helper method that runs the subtests for the different helper methods.""" + for test_value in test_values: + api_response = test_value["api response"] + expected_lines = test_value["expected_lines"] + + with self.subTest(method=method, api_response=api_response, expected_lines=expected_lines): + self.bot.api_client.get.return_value = api_response + + expected_output = "\n".join(default_header + expected_lines) + actual_output = asyncio.run(method(self.member)) + + self.assertEqual(expected_output, actual_output) + + def test_basic_user_infraction_counts_returns_correct_strings(self): + """The method should correctly list both the total and active number of non-hidden infractions.""" + test_values = ( + # No infractions means zero counts + { + "api response": [], + "expected_lines": ["Total: 0", "Active: 0"], + }, + # Simple, single-infraction dictionaries + { + "api response": [{"type": "ban", "active": True}], + "expected_lines": ["Total: 1", "Active: 1"], + }, + { + "api response": [{"type": "ban", "active": False}], + "expected_lines": ["Total: 1", "Active: 0"], + }, + # Multiple infractions with various `active` status + { + "api response": [ + {"type": "ban", "active": True}, + {"type": "kick", "active": False}, + {"type": "ban", "active": True}, + {"type": "ban", "active": False}, + ], + "expected_lines": ["Total: 4", "Active: 2"], + }, + ) + + header = ["**Infractions**"] + + self._method_subtests(self.cog.basic_user_infraction_counts, test_values, header) + + def test_expanded_user_infraction_counts_returns_correct_strings(self): + """The method should correctly list the total and active number of all infractions split by infraction type.""" + test_values = ( + { + "api response": [], + "expected_lines": ["This user has never received an infraction."], + }, + # Shows non-hidden inactive infraction as expected + { + "api response": [{"type": "kick", "active": False, "hidden": False}], + "expected_lines": ["Kicks: 1"], + }, + # Shows non-hidden active infraction as expected + { + "api response": [{"type": "mute", "active": True, "hidden": False}], + "expected_lines": ["Mutes: 1 (1 active)"], + }, + # Shows hidden inactive infraction as expected + { + "api response": [{"type": "superstar", "active": False, "hidden": True}], + "expected_lines": ["Superstars: 1"], + }, + # Shows hidden active infraction as expected + { + "api response": [{"type": "ban", "active": True, "hidden": True}], + "expected_lines": ["Bans: 1 (1 active)"], + }, + # Correctly displays tally of multiple infractions of mixed properties in alphabetical order + { + "api response": [ + {"type": "kick", "active": False, "hidden": True}, + {"type": "ban", "active": True, "hidden": True}, + {"type": "superstar", "active": True, "hidden": True}, + {"type": "mute", "active": True, "hidden": True}, + {"type": "ban", "active": False, "hidden": False}, + {"type": "note", "active": False, "hidden": True}, + {"type": "note", "active": False, "hidden": True}, + {"type": "warn", "active": False, "hidden": False}, + {"type": "note", "active": False, "hidden": True}, + ], + "expected_lines": [ + "Bans: 2 (1 active)", + "Kicks: 1", + "Mutes: 1 (1 active)", + "Notes: 3", + "Superstars: 1 (1 active)", + "Warns: 1", + ], + }, + ) + + header = ["**Infractions**"] + + self._method_subtests(self.cog.expanded_user_infraction_counts, test_values, header) + + def test_user_nomination_counts_returns_correct_strings(self): + """The method should list the number of active and historical nominations for the user.""" + test_values = ( + { + "api response": [], + "expected_lines": ["This user has never been nominated."], + }, + { + "api response": [{'active': True}], + "expected_lines": ["This user is **currently** nominated (1 nomination in total)."], + }, + { + "api response": [{'active': True}, {'active': False}], + "expected_lines": ["This user is **currently** nominated (2 nominations in total)."], + }, + { + "api response": [{'active': False}], + "expected_lines": ["This user has 1 historical nomination, but is currently not nominated."], + }, + { + "api response": [{'active': False}, {'active': False}], + "expected_lines": ["This user has 2 historical nominations, but is currently not nominated."], + }, + + ) + + header = ["**Nominations**"] + + self._method_subtests(self.cog.user_nomination_counts, test_values, header) + + +@unittest.mock.patch("bot.cogs.info.information.time_since", new=unittest.mock.MagicMock(return_value="1 year ago")) +@unittest.mock.patch("bot.cogs.info.information.constants.MODERATION_CHANNELS", new=[50]) +class UserEmbedTests(unittest.TestCase): + """Tests for the creation of the `!user` embed.""" + + def setUp(self): + """Common set-up steps done before for each test.""" + self.bot = helpers.MockBot() + self.bot.api_client.get = unittest.mock.AsyncMock() + self.cog = information.Information(self.bot) + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self): + """The embed should use the string representation of the user if they don't have a nick.""" + ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) + user = helpers.MockMember() + user.nick = None + user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") + + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + self.assertEqual(embed.title, "Mr. Hemlock") + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + def test_create_user_embed_uses_nick_in_title_if_available(self): + """The embed should use the nick if it's available.""" + ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) + user = helpers.MockMember() + user.nick = "Cat lover" + user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") + + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)") + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + def test_create_user_embed_ignores_everyone_role(self): + """Created `!user` embeds should not contain mention of the @everyone-role.""" + ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) + admins_role = helpers.MockRole(name='Admins') + admins_role.colour = 100 + + # A `MockMember` has the @Everyone role by default; we add the Admins to that. + user = helpers.MockMember(roles=[admins_role], top_role=admins_role) + + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + self.assertIn("&Admins", embed.description) + self.assertNotIn("&Everyone", embed.description) + + @unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=unittest.mock.AsyncMock) + @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=unittest.mock.AsyncMock) + def test_create_user_embed_expanded_information_in_moderation_channels(self, nomination_counts, infraction_counts): + """The embed should contain expanded infractions and nomination info in mod channels.""" + ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=50)) + + moderators_role = helpers.MockRole(name='Moderators') + moderators_role.colour = 100 + + infraction_counts.return_value = "expanded infractions info" + nomination_counts.return_value = "nomination info" + + user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + infraction_counts.assert_called_once_with(user) + nomination_counts.assert_called_once_with(user) + + self.assertEqual( + textwrap.dedent(f""" + **User Information** + Created: {"1 year ago"} + Profile: {user.mention} + ID: {user.id} + + **Member Information** + Joined: {"1 year ago"} + Roles: &Moderators + + expanded infractions info + + nomination info + """).strip(), + embed.description + ) + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=unittest.mock.AsyncMock) + def test_create_user_embed_basic_information_outside_of_moderation_channels(self, infraction_counts): + """The embed should contain only basic infraction data outside of mod channels.""" + ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=100)) + + moderators_role = helpers.MockRole(name='Moderators') + moderators_role.colour = 100 + + infraction_counts.return_value = "basic infractions info" + + user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + infraction_counts.assert_called_once_with(user) + + self.assertEqual( + textwrap.dedent(f""" + **User Information** + Created: {"1 year ago"} + Profile: {user.mention} + ID: {user.id} + + **Member Information** + Joined: {"1 year ago"} + Roles: &Moderators + + basic infractions info + """).strip(), + embed.description + ) + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self): + """The embed should be created with the colour of the top role, if a top role is available.""" + ctx = helpers.MockContext() + + moderators_role = helpers.MockRole(name='Moderators') + moderators_role.colour = 100 + + user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + self.assertEqual(embed.colour, discord.Colour(moderators_role.colour)) + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self): + """The embed should be created with a blurple colour if the user has no assigned roles.""" + ctx = helpers.MockContext() + + user = helpers.MockMember(id=217) + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + self.assertEqual(embed.colour, discord.Colour.blurple()) + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self): + """The embed thumbnail should be set to the user's avatar in `png` format.""" + ctx = helpers.MockContext() + + user = helpers.MockMember(id=217) + user.avatar_url_as.return_value = "avatar url" + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + user.avatar_url_as.assert_called_once_with(static_format="png") + self.assertEqual(embed.thumbnail.url, "avatar url") + + +@unittest.mock.patch("bot.cogs.info.information.constants") +class UserCommandTests(unittest.TestCase): + """Tests for the `!user` command.""" + + def setUp(self): + """Set up steps executed before each test is run.""" + self.bot = helpers.MockBot() + self.cog = information.Information(self.bot) + + self.moderator_role = helpers.MockRole(name="Moderators", id=2, position=10) + self.flautist_role = helpers.MockRole(name="Flautists", id=3, position=2) + self.bassist_role = helpers.MockRole(name="Bassists", id=4, position=3) + + self.author = helpers.MockMember(id=1, name="syntaxaire") + self.moderator = helpers.MockMember(id=2, name="riffautae", roles=[self.moderator_role]) + self.target = helpers.MockMember(id=3, name="__fluzz__") + + def test_regular_member_cannot_target_another_member(self, constants): + """A regular user should not be able to use `!user` targeting another user.""" + constants.MODERATION_ROLES = [self.moderator_role.id] + + ctx = helpers.MockContext(author=self.author) + + asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target)) + + ctx.send.assert_called_once_with("You may not use this command on users other than yourself.") + + def test_regular_member_cannot_use_command_outside_of_bot_commands(self, constants): + """A regular user should not be able to use this command outside of bot-commands.""" + constants.MODERATION_ROLES = [self.moderator_role.id] + constants.STAFF_ROLES = [self.moderator_role.id] + constants.Channels.bot_commands = 50 + + ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=100)) + + msg = "Sorry, but you may only use this command within <#50>." + with self.assertRaises(InWhitelistCheckFailure, msg=msg): + asyncio.run(self.cog.user_info.callback(self.cog, ctx)) + + @unittest.mock.patch("bot.cogs.info.information.Information.create_user_embed") + def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants): + """A regular user should be allowed to use `!user` targeting themselves in bot-commands.""" + constants.STAFF_ROLES = [self.moderator_role.id] + constants.Channels.bot_commands = 50 + + ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) + + asyncio.run(self.cog.user_info.callback(self.cog, ctx)) + + create_embed.assert_called_once_with(ctx, self.author) + ctx.send.assert_called_once() + + @unittest.mock.patch("bot.cogs.info.information.Information.create_user_embed") + def test_regular_user_can_explicitly_target_themselves(self, create_embed, constants): + """A user should target itself with `!user` when a `user` argument was not provided.""" + constants.STAFF_ROLES = [self.moderator_role.id] + constants.Channels.bot_commands = 50 + + ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) + + asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.author)) + + create_embed.assert_called_once_with(ctx, self.author) + ctx.send.assert_called_once() + + @unittest.mock.patch("bot.cogs.info.information.Information.create_user_embed") + def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants): + """Staff members should be able to bypass the bot-commands channel restriction.""" + constants.STAFF_ROLES = [self.moderator_role.id] + constants.Channels.bot_commands = 50 + + ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=200)) + + asyncio.run(self.cog.user_info.callback(self.cog, ctx)) + + create_embed.assert_called_once_with(ctx, self.moderator) + ctx.send.assert_called_once() + + @unittest.mock.patch("bot.cogs.info.information.Information.create_user_embed") + def test_moderators_can_target_another_member(self, create_embed, constants): + """A moderator should be able to use `!user` targeting another user.""" + constants.MODERATION_ROLES = [self.moderator_role.id] + constants.STAFF_ROLES = [self.moderator_role.id] + + ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=50)) + + asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target)) + + create_embed.assert_called_once_with(ctx, self.target) + ctx.send.assert_called_once() diff --git a/tests/bot/cogs/moderation/infraction/__init__.py b/tests/bot/cogs/moderation/infraction/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/cogs/moderation/infraction/test_infractions.py b/tests/bot/cogs/moderation/infraction/test_infractions.py new file mode 100644 index 000000000..a79042557 --- /dev/null +++ b/tests/bot/cogs/moderation/infraction/test_infractions.py @@ -0,0 +1,55 @@ +import textwrap +import unittest +from unittest.mock import AsyncMock, Mock, patch + +from bot.cogs.moderation.infraction.infractions import Infractions +from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole + + +class TruncationTests(unittest.IsolatedAsyncioTestCase): + """Tests for ban and kick command reason truncation.""" + + def setUp(self): + self.bot = MockBot() + self.cog = Infractions(self.bot) + self.user = MockMember(id=1234, top_role=MockRole(id=3577, position=10)) + self.target = MockMember(id=1265, top_role=MockRole(id=9876, position=0)) + self.guild = MockGuild(id=4567) + self.ctx = MockContext(bot=self.bot, author=self.user, guild=self.guild) + + @patch("bot.cogs.moderation.infraction.utils.get_active_infraction") + @patch("bot.cogs.moderation.infraction.utils.post_infraction") + async def test_apply_ban_reason_truncation(self, post_infraction_mock, get_active_mock): + """Should truncate reason for `ctx.guild.ban`.""" + get_active_mock.return_value = None + post_infraction_mock.return_value = {"foo": "bar"} + + self.cog.apply_infraction = AsyncMock() + self.bot.get_cog.return_value = AsyncMock() + self.cog.mod_log.ignore = Mock() + self.ctx.guild.ban = Mock() + + await self.cog.apply_ban(self.ctx, self.target, "foo bar" * 3000) + self.ctx.guild.ban.assert_called_once_with( + self.target, + reason=textwrap.shorten("foo bar" * 3000, 512, placeholder="..."), + delete_message_days=0 + ) + self.cog.apply_infraction.assert_awaited_once_with( + self.ctx, {"foo": "bar"}, self.target, self.ctx.guild.ban.return_value + ) + + @patch("bot.cogs.moderation.infraction.utils.post_infraction") + async def test_apply_kick_reason_truncation(self, post_infraction_mock): + """Should truncate reason for `Member.kick`.""" + post_infraction_mock.return_value = {"foo": "bar"} + + self.cog.apply_infraction = AsyncMock() + self.cog.mod_log.ignore = Mock() + self.target.kick = Mock() + + await self.cog.apply_kick(self.ctx, self.target, "foo bar" * 3000) + self.target.kick.assert_called_once_with(reason=textwrap.shorten("foo bar" * 3000, 512, placeholder="...")) + self.cog.apply_infraction.assert_awaited_once_with( + self.ctx, {"foo": "bar"}, self.target, self.target.kick.return_value + ) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 435a1cd51..5e4d90251 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, call, patch import aiohttp import discord -from bot.cogs.moderation import Incidents, incidents +from bot.cogs.moderation import incidents from bot.constants import Colours from tests.helpers import ( MockAsyncWebhook, @@ -290,7 +290,7 @@ class TestIncidents(unittest.IsolatedAsyncioTestCase): Note that this will not schedule `crawl_incidents` in the background, as everything is being mocked. The `crawl_task` attribute will end up being None. """ - self.cog_instance = Incidents(MockBot()) + self.cog_instance = incidents.Incidents(MockBot()) @patch("asyncio.sleep", AsyncMock()) # Prevent the coro from sleeping to speed up the test diff --git a/tests/bot/cogs/moderation/test_infractions.py b/tests/bot/cogs/moderation/test_infractions.py deleted file mode 100644 index df38090fb..000000000 --- a/tests/bot/cogs/moderation/test_infractions.py +++ /dev/null @@ -1,55 +0,0 @@ -import textwrap -import unittest -from unittest.mock import AsyncMock, Mock, patch - -from bot.cogs.moderation.infraction.infractions import Infractions -from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole - - -class TruncationTests(unittest.IsolatedAsyncioTestCase): - """Tests for ban and kick command reason truncation.""" - - def setUp(self): - self.bot = MockBot() - self.cog = Infractions(self.bot) - self.user = MockMember(id=1234, top_role=MockRole(id=3577, position=10)) - self.target = MockMember(id=1265, top_role=MockRole(id=9876, position=0)) - self.guild = MockGuild(id=4567) - self.ctx = MockContext(bot=self.bot, author=self.user, guild=self.guild) - - @patch("bot.cogs.moderation.utils.get_active_infraction") - @patch("bot.cogs.moderation.utils.post_infraction") - async def test_apply_ban_reason_truncation(self, post_infraction_mock, get_active_mock): - """Should truncate reason for `ctx.guild.ban`.""" - get_active_mock.return_value = None - post_infraction_mock.return_value = {"foo": "bar"} - - self.cog.apply_infraction = AsyncMock() - self.bot.get_cog.return_value = AsyncMock() - self.cog.mod_log.ignore = Mock() - self.ctx.guild.ban = Mock() - - await self.cog.apply_ban(self.ctx, self.target, "foo bar" * 3000) - self.ctx.guild.ban.assert_called_once_with( - self.target, - reason=textwrap.shorten("foo bar" * 3000, 512, placeholder="..."), - delete_message_days=0 - ) - self.cog.apply_infraction.assert_awaited_once_with( - self.ctx, {"foo": "bar"}, self.target, self.ctx.guild.ban.return_value - ) - - @patch("bot.cogs.moderation.utils.post_infraction") - async def test_apply_kick_reason_truncation(self, post_infraction_mock): - """Should truncate reason for `Member.kick`.""" - post_infraction_mock.return_value = {"foo": "bar"} - - self.cog.apply_infraction = AsyncMock() - self.cog.mod_log.ignore = Mock() - self.target.kick = Mock() - - await self.cog.apply_kick(self.ctx, self.target, "foo bar" * 3000) - self.target.kick.assert_called_once_with(reason=textwrap.shorten("foo bar" * 3000, 512, placeholder="...")) - self.cog.apply_infraction.assert_awaited_once_with( - self.ctx, {"foo": "bar"}, self.target, self.target.kick.return_value - ) diff --git a/tests/bot/cogs/moderation/test_slowmode.py b/tests/bot/cogs/moderation/test_slowmode.py new file mode 100644 index 000000000..f442814c8 --- /dev/null +++ b/tests/bot/cogs/moderation/test_slowmode.py @@ -0,0 +1,111 @@ +import unittest +from unittest import mock + +from dateutil.relativedelta import relativedelta + +from bot.cogs.moderation.slowmode import Slowmode +from bot.constants import Emojis +from tests.helpers import MockBot, MockContext, MockTextChannel + + +class SlowmodeTests(unittest.IsolatedAsyncioTestCase): + + def setUp(self) -> None: + self.bot = MockBot() + self.cog = Slowmode(self.bot) + self.ctx = MockContext() + + async def test_get_slowmode_no_channel(self) -> None: + """Get slowmode without a given channel.""" + self.ctx.channel = MockTextChannel(name='python-general', slowmode_delay=5) + + await self.cog.get_slowmode(self.cog, self.ctx, None) + self.ctx.send.assert_called_once_with("The slowmode delay for #python-general is 5 seconds.") + + async def test_get_slowmode_with_channel(self) -> None: + """Get slowmode with a given channel.""" + text_channel = MockTextChannel(name='python-language', slowmode_delay=2) + + await self.cog.get_slowmode(self.cog, self.ctx, text_channel) + self.ctx.send.assert_called_once_with('The slowmode delay for #python-language is 2 seconds.') + + async def test_set_slowmode_no_channel(self) -> None: + """Set slowmode without a given channel.""" + test_cases = ( + ('helpers', 23, True, f'{Emojis.check_mark} The slowmode delay for #helpers is now 23 seconds.'), + ('mods', 76526, False, f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.'), + ('admins', 97, True, f'{Emojis.check_mark} The slowmode delay for #admins is now 1 minute and 37 seconds.') + ) + + for channel_name, seconds, edited, result_msg in test_cases: + with self.subTest( + channel_mention=channel_name, + seconds=seconds, + edited=edited, + result_msg=result_msg + ): + self.ctx.channel = MockTextChannel(name=channel_name) + + await self.cog.set_slowmode(self.cog, self.ctx, None, relativedelta(seconds=seconds)) + + if edited: + self.ctx.channel.edit.assert_awaited_once_with(slowmode_delay=float(seconds)) + else: + self.ctx.channel.edit.assert_not_called() + + self.ctx.send.assert_called_once_with(result_msg) + + self.ctx.reset_mock() + + async def test_set_slowmode_with_channel(self) -> None: + """Set slowmode with a given channel.""" + test_cases = ( + ('bot-commands', 12, True, f'{Emojis.check_mark} The slowmode delay for #bot-commands is now 12 seconds.'), + ('mod-spam', 21, True, f'{Emojis.check_mark} The slowmode delay for #mod-spam is now 21 seconds.'), + ('admin-spam', 4323598, False, f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.') + ) + + for channel_name, seconds, edited, result_msg in test_cases: + with self.subTest( + channel_mention=channel_name, + seconds=seconds, + edited=edited, + result_msg=result_msg + ): + text_channel = MockTextChannel(name=channel_name) + + await self.cog.set_slowmode(self.cog, self.ctx, text_channel, relativedelta(seconds=seconds)) + + if edited: + text_channel.edit.assert_awaited_once_with(slowmode_delay=float(seconds)) + else: + text_channel.edit.assert_not_called() + + self.ctx.send.assert_called_once_with(result_msg) + + self.ctx.reset_mock() + + async def test_reset_slowmode_no_channel(self) -> None: + """Reset slowmode without a given channel.""" + self.ctx.channel = MockTextChannel(name='careers', slowmode_delay=6) + + await self.cog.reset_slowmode(self.cog, self.ctx, None) + self.ctx.send.assert_called_once_with( + f'{Emojis.check_mark} The slowmode delay for #careers has been reset to 0 seconds.' + ) + + async def test_reset_slowmode_with_channel(self) -> None: + """Reset slowmode with a given channel.""" + text_channel = MockTextChannel(name='meta', slowmode_delay=1) + + await self.cog.reset_slowmode(self.cog, self.ctx, text_channel) + self.ctx.send.assert_called_once_with( + f'{Emojis.check_mark} The slowmode delay for #meta has been reset to 0 seconds.' + ) + + @mock.patch("bot.cogs.moderation.slowmode.with_role_check") + @mock.patch("bot.cogs.moderation.slowmode.MODERATION_ROLES", new=(1, 2, 3)) + def test_cog_check(self, role_check): + """Role check is called with `MODERATION_ROLES`""" + self.cog.cog_check(self.ctx) + role_check.assert_called_once_with(self.ctx, *(1, 2, 3)) diff --git a/tests/bot/cogs/sync/__init__.py b/tests/bot/cogs/sync/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py deleted file mode 100644 index 84d036405..000000000 --- a/tests/bot/cogs/sync/test_base.py +++ /dev/null @@ -1,404 +0,0 @@ -import asyncio -import unittest -from unittest import mock - -import discord - -from bot import constants -from bot.api import ResponseCodeError -from bot.cogs.backend.sync import Syncer, _Diff -from tests import helpers - - -class TestSyncer(Syncer): - """Syncer subclass with mocks for abstract methods for testing purposes.""" - - name = "test" - _get_diff = mock.AsyncMock() - _sync = mock.AsyncMock() - - -class SyncerBaseTests(unittest.TestCase): - """Tests for the syncer base class.""" - - def setUp(self): - self.bot = helpers.MockBot() - - def test_instantiation_fails_without_abstract_methods(self): - """The class must have abstract methods implemented.""" - with self.assertRaisesRegex(TypeError, "Can't instantiate abstract class"): - Syncer(self.bot) - - -class SyncerSendPromptTests(unittest.IsolatedAsyncioTestCase): - """Tests for sending the sync confirmation prompt.""" - - def setUp(self): - self.bot = helpers.MockBot() - self.syncer = TestSyncer(self.bot) - - def mock_get_channel(self): - """Fixture to return a mock channel and message for when `get_channel` is used.""" - self.bot.reset_mock() - - mock_channel = helpers.MockTextChannel() - mock_message = helpers.MockMessage() - - mock_channel.send.return_value = mock_message - self.bot.get_channel.return_value = mock_channel - - return mock_channel, mock_message - - def mock_fetch_channel(self): - """Fixture to return a mock channel and message for when `fetch_channel` is used.""" - self.bot.reset_mock() - - mock_channel = helpers.MockTextChannel() - mock_message = helpers.MockMessage() - - self.bot.get_channel.return_value = None - mock_channel.send.return_value = mock_message - self.bot.fetch_channel.return_value = mock_channel - - return mock_channel, mock_message - - async def test_send_prompt_edits_and_returns_message(self): - """The given message should be edited to display the prompt and then should be returned.""" - msg = helpers.MockMessage() - ret_val = await self.syncer._send_prompt(msg) - - msg.edit.assert_called_once() - self.assertIn("content", msg.edit.call_args[1]) - self.assertEqual(ret_val, msg) - - async def test_send_prompt_gets_dev_core_channel(self): - """The dev-core channel should be retrieved if an extant message isn't given.""" - subtests = ( - (self.bot.get_channel, self.mock_get_channel), - (self.bot.fetch_channel, self.mock_fetch_channel), - ) - - for method, mock_ in subtests: - with self.subTest(method=method, msg=mock_.__name__): - mock_() - await self.syncer._send_prompt() - - method.assert_called_once_with(constants.Channels.dev_core) - - async def test_send_prompt_returns_none_if_channel_fetch_fails(self): - """None should be returned if there's an HTTPException when fetching the channel.""" - self.bot.get_channel.return_value = None - self.bot.fetch_channel.side_effect = discord.HTTPException(mock.MagicMock(), "test error!") - - ret_val = await self.syncer._send_prompt() - - self.assertIsNone(ret_val) - - async def test_send_prompt_sends_and_returns_new_message_if_not_given(self): - """A new message mentioning core devs should be sent and returned if message isn't given.""" - for mock_ in (self.mock_get_channel, self.mock_fetch_channel): - with self.subTest(msg=mock_.__name__): - mock_channel, mock_message = mock_() - ret_val = await self.syncer._send_prompt() - - mock_channel.send.assert_called_once() - self.assertIn(self.syncer._CORE_DEV_MENTION, mock_channel.send.call_args[0][0]) - self.assertEqual(ret_val, mock_message) - - async def test_send_prompt_adds_reactions(self): - """The message should have reactions for confirmation added.""" - extant_message = helpers.MockMessage() - subtests = ( - (extant_message, lambda: (None, extant_message)), - (None, self.mock_get_channel), - (None, self.mock_fetch_channel), - ) - - for message_arg, mock_ in subtests: - subtest_msg = "Extant message" if mock_.__name__ == "" else mock_.__name__ - - with self.subTest(msg=subtest_msg): - _, mock_message = mock_() - await self.syncer._send_prompt(message_arg) - - calls = [mock.call(emoji) for emoji in self.syncer._REACTION_EMOJIS] - mock_message.add_reaction.assert_has_calls(calls) - - -class SyncerConfirmationTests(unittest.IsolatedAsyncioTestCase): - """Tests for waiting for a sync confirmation reaction on the prompt.""" - - def setUp(self): - self.bot = helpers.MockBot() - self.syncer = TestSyncer(self.bot) - self.core_dev_role = helpers.MockRole(id=constants.Roles.core_developers) - - @staticmethod - def get_message_reaction(emoji): - """Fixture to return a mock message an reaction from the given `emoji`.""" - message = helpers.MockMessage() - reaction = helpers.MockReaction(emoji=emoji, message=message) - - return message, reaction - - def test_reaction_check_for_valid_emoji_and_authors(self): - """Should return True if authors are identical or are a bot and a core dev, respectively.""" - user_subtests = ( - ( - helpers.MockMember(id=77), - helpers.MockMember(id=77), - "identical users", - ), - ( - helpers.MockMember(id=77, bot=True), - helpers.MockMember(id=43, roles=[self.core_dev_role]), - "bot author and core-dev reactor", - ), - ) - - for emoji in self.syncer._REACTION_EMOJIS: - for author, user, msg in user_subtests: - with self.subTest(author=author, user=user, emoji=emoji, msg=msg): - message, reaction = self.get_message_reaction(emoji) - ret_val = self.syncer._reaction_check(author, message, reaction, user) - - self.assertTrue(ret_val) - - def test_reaction_check_for_invalid_reactions(self): - """Should return False for invalid reaction events.""" - valid_emoji = self.syncer._REACTION_EMOJIS[0] - subtests = ( - ( - helpers.MockMember(id=77), - *self.get_message_reaction(valid_emoji), - helpers.MockMember(id=43, roles=[self.core_dev_role]), - "users are not identical", - ), - ( - helpers.MockMember(id=77, bot=True), - *self.get_message_reaction(valid_emoji), - helpers.MockMember(id=43), - "reactor lacks the core-dev role", - ), - ( - helpers.MockMember(id=77, bot=True, roles=[self.core_dev_role]), - *self.get_message_reaction(valid_emoji), - helpers.MockMember(id=77, bot=True, roles=[self.core_dev_role]), - "reactor is a bot", - ), - ( - helpers.MockMember(id=77), - helpers.MockMessage(id=95), - helpers.MockReaction(emoji=valid_emoji, message=helpers.MockMessage(id=26)), - helpers.MockMember(id=77), - "messages are not identical", - ), - ( - helpers.MockMember(id=77), - *self.get_message_reaction("InVaLiD"), - helpers.MockMember(id=77), - "emoji is invalid", - ), - ) - - for *args, msg in subtests: - kwargs = dict(zip(("author", "message", "reaction", "user"), args)) - with self.subTest(**kwargs, msg=msg): - ret_val = self.syncer._reaction_check(*args) - self.assertFalse(ret_val) - - async def test_wait_for_confirmation(self): - """The message should always be edited and only return True if the emoji is a check mark.""" - subtests = ( - (constants.Emojis.check_mark, True, None), - ("InVaLiD", False, None), - (None, False, asyncio.TimeoutError), - ) - - for emoji, ret_val, side_effect in subtests: - for bot in (True, False): - with self.subTest(emoji=emoji, ret_val=ret_val, side_effect=side_effect, bot=bot): - # Set up mocks - message = helpers.MockMessage() - member = helpers.MockMember(bot=bot) - - self.bot.wait_for.reset_mock() - self.bot.wait_for.return_value = (helpers.MockReaction(emoji=emoji), None) - self.bot.wait_for.side_effect = side_effect - - # Call the function - actual_return = await self.syncer._wait_for_confirmation(member, message) - - # Perform assertions - self.bot.wait_for.assert_called_once() - self.assertIn("reaction_add", self.bot.wait_for.call_args[0]) - - message.edit.assert_called_once() - kwargs = message.edit.call_args[1] - self.assertIn("content", kwargs) - - # Core devs should only be mentioned if the author is a bot. - if bot: - self.assertIn(self.syncer._CORE_DEV_MENTION, kwargs["content"]) - else: - self.assertNotIn(self.syncer._CORE_DEV_MENTION, kwargs["content"]) - - self.assertIs(actual_return, ret_val) - - -class SyncerSyncTests(unittest.IsolatedAsyncioTestCase): - """Tests for main function orchestrating the sync.""" - - def setUp(self): - self.bot = helpers.MockBot(user=helpers.MockMember(bot=True)) - self.syncer = TestSyncer(self.bot) - - async def test_sync_respects_confirmation_result(self): - """The sync should abort if confirmation fails and continue if confirmed.""" - mock_message = helpers.MockMessage() - subtests = ( - (True, mock_message), - (False, None), - ) - - for confirmed, message in subtests: - with self.subTest(confirmed=confirmed): - self.syncer._sync.reset_mock() - self.syncer._get_diff.reset_mock() - - diff = _Diff({1, 2, 3}, {4, 5}, None) - self.syncer._get_diff.return_value = diff - self.syncer._get_confirmation_result = mock.AsyncMock( - return_value=(confirmed, message) - ) - - guild = helpers.MockGuild() - await self.syncer.sync(guild) - - self.syncer._get_diff.assert_called_once_with(guild) - self.syncer._get_confirmation_result.assert_called_once() - - if confirmed: - self.syncer._sync.assert_called_once_with(diff) - else: - self.syncer._sync.assert_not_called() - - async def test_sync_diff_size(self): - """The diff size should be correctly calculated.""" - subtests = ( - (6, _Diff({1, 2}, {3, 4}, {5, 6})), - (5, _Diff({1, 2, 3}, None, {4, 5})), - (0, _Diff(None, None, None)), - (0, _Diff(set(), set(), set())), - ) - - for size, diff in subtests: - with self.subTest(size=size, diff=diff): - self.syncer._get_diff.reset_mock() - self.syncer._get_diff.return_value = diff - self.syncer._get_confirmation_result = mock.AsyncMock(return_value=(False, None)) - - guild = helpers.MockGuild() - await self.syncer.sync(guild) - - self.syncer._get_diff.assert_called_once_with(guild) - self.syncer._get_confirmation_result.assert_called_once() - self.assertEqual(self.syncer._get_confirmation_result.call_args[0][0], size) - - async def test_sync_message_edited(self): - """The message should be edited if one was sent, even if the sync has an API error.""" - subtests = ( - (None, None, False), - (helpers.MockMessage(), None, True), - (helpers.MockMessage(), ResponseCodeError(mock.MagicMock()), True), - ) - - for message, side_effect, should_edit in subtests: - with self.subTest(message=message, side_effect=side_effect, should_edit=should_edit): - self.syncer._sync.side_effect = side_effect - self.syncer._get_confirmation_result = mock.AsyncMock( - return_value=(True, message) - ) - - guild = helpers.MockGuild() - await self.syncer.sync(guild) - - if should_edit: - message.edit.assert_called_once() - self.assertIn("content", message.edit.call_args[1]) - - async def test_sync_confirmation_context_redirect(self): - """If ctx is given, a new message should be sent and author should be ctx's author.""" - mock_member = helpers.MockMember() - subtests = ( - (None, self.bot.user, None), - (helpers.MockContext(author=mock_member), mock_member, helpers.MockMessage()), - ) - - for ctx, author, message in subtests: - with self.subTest(ctx=ctx, author=author, message=message): - if ctx is not None: - ctx.send.return_value = message - - # Make sure `_get_diff` returns a MagicMock, not an AsyncMock - self.syncer._get_diff.return_value = mock.MagicMock() - - self.syncer._get_confirmation_result = mock.AsyncMock(return_value=(False, None)) - - guild = helpers.MockGuild() - await self.syncer.sync(guild, ctx) - - if ctx is not None: - ctx.send.assert_called_once() - - self.syncer._get_confirmation_result.assert_called_once() - self.assertEqual(self.syncer._get_confirmation_result.call_args[0][1], author) - self.assertEqual(self.syncer._get_confirmation_result.call_args[0][2], message) - - @mock.patch.object(constants.Sync, "max_diff", new=3) - async def test_confirmation_result_small_diff(self): - """Should always return True and the given message if the diff size is too small.""" - author = helpers.MockMember() - expected_message = helpers.MockMessage() - - for size in (3, 2): # pragma: no cover - with self.subTest(size=size): - self.syncer._send_prompt = mock.AsyncMock() - self.syncer._wait_for_confirmation = mock.AsyncMock() - - coro = self.syncer._get_confirmation_result(size, author, expected_message) - result, actual_message = await coro - - self.assertTrue(result) - self.assertEqual(actual_message, expected_message) - self.syncer._send_prompt.assert_not_called() - self.syncer._wait_for_confirmation.assert_not_called() - - @mock.patch.object(constants.Sync, "max_diff", new=3) - async def test_confirmation_result_large_diff(self): - """Should return True if confirmed and False if _send_prompt fails or aborted.""" - author = helpers.MockMember() - mock_message = helpers.MockMessage() - - subtests = ( - (True, mock_message, True, "confirmed"), - (False, None, False, "_send_prompt failed"), - (False, mock_message, False, "aborted"), - ) - - for expected_result, expected_message, confirmed, msg in subtests: # pragma: no cover - with self.subTest(msg=msg): - self.syncer._send_prompt = mock.AsyncMock(return_value=expected_message) - self.syncer._wait_for_confirmation = mock.AsyncMock(return_value=confirmed) - - coro = self.syncer._get_confirmation_result(4, author) - actual_result, actual_message = await coro - - self.syncer._send_prompt.assert_called_once_with(None) # message defaults to None - self.assertIs(actual_result, expected_result) - self.assertEqual(actual_message, expected_message) - - if expected_message: - self.syncer._wait_for_confirmation.assert_called_once_with( - author, expected_message - ) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py deleted file mode 100644 index ea7d090ba..000000000 --- a/tests/bot/cogs/sync/test_cog.py +++ /dev/null @@ -1,415 +0,0 @@ -import unittest -from unittest import mock - -import discord - -from bot import constants -from bot.api import ResponseCodeError -from bot.cogs.backend import sync -from bot.cogs.backend.sync import Syncer -from tests import helpers -from tests.base import CommandTestCase - - -class SyncExtensionTests(unittest.IsolatedAsyncioTestCase): - """Tests for the sync extension.""" - - @staticmethod - def test_extension_setup(): - """The Sync cog should be added.""" - bot = helpers.MockBot() - sync.setup(bot) - bot.add_cog.assert_called_once() - - -class SyncCogTestCase(unittest.IsolatedAsyncioTestCase): - """Base class for Sync cog tests. Sets up patches for syncers.""" - - def setUp(self): - self.bot = helpers.MockBot() - - self.role_syncer_patcher = mock.patch( - "bot.cogs.sync.syncers.RoleSyncer", - autospec=Syncer, - spec_set=True - ) - self.user_syncer_patcher = mock.patch( - "bot.cogs.sync.syncers.UserSyncer", - autospec=Syncer, - spec_set=True - ) - self.RoleSyncer = self.role_syncer_patcher.start() - self.UserSyncer = self.user_syncer_patcher.start() - - self.cog = sync.Sync(self.bot) - - def tearDown(self): - self.role_syncer_patcher.stop() - self.user_syncer_patcher.stop() - - @staticmethod - def response_error(status: int) -> ResponseCodeError: - """Fixture to return a ResponseCodeError with the given status code.""" - response = mock.MagicMock() - response.status = status - - return ResponseCodeError(response) - - -class SyncCogTests(SyncCogTestCase): - """Tests for the Sync cog.""" - - @mock.patch.object(sync.Sync, "sync_guild", new_callable=mock.MagicMock) - def test_sync_cog_init(self, sync_guild): - """Should instantiate syncers and run a sync for the guild.""" - # Reset because a Sync cog was already instantiated in setUp. - self.RoleSyncer.reset_mock() - self.UserSyncer.reset_mock() - self.bot.loop.create_task = mock.MagicMock() - - mock_sync_guild_coro = mock.MagicMock() - sync_guild.return_value = mock_sync_guild_coro - - sync.Sync(self.bot) - - self.RoleSyncer.assert_called_once_with(self.bot) - self.UserSyncer.assert_called_once_with(self.bot) - sync_guild.assert_called_once_with() - self.bot.loop.create_task.assert_called_once_with(mock_sync_guild_coro) - - async def test_sync_cog_sync_guild(self): - """Roles and users should be synced only if a guild is successfully retrieved.""" - for guild in (helpers.MockGuild(), None): - with self.subTest(guild=guild): - self.bot.reset_mock() - self.cog.role_syncer.reset_mock() - self.cog.user_syncer.reset_mock() - - self.bot.get_guild = mock.MagicMock(return_value=guild) - - await self.cog.sync_guild() - - self.bot.wait_until_guild_available.assert_called_once() - self.bot.get_guild.assert_called_once_with(constants.Guild.id) - - if guild is None: - self.cog.role_syncer.sync.assert_not_called() - self.cog.user_syncer.sync.assert_not_called() - else: - self.cog.role_syncer.sync.assert_called_once_with(guild) - self.cog.user_syncer.sync.assert_called_once_with(guild) - - async def patch_user_helper(self, side_effect: BaseException) -> None: - """Helper to set a side effect for bot.api_client.patch and then assert it is called.""" - self.bot.api_client.patch.reset_mock(side_effect=True) - self.bot.api_client.patch.side_effect = side_effect - - user_id, updated_information = 5, {"key": 123} - await self.cog.patch_user(user_id, updated_information) - - self.bot.api_client.patch.assert_called_once_with( - f"bot/users/{user_id}", - json=updated_information, - ) - - async def test_sync_cog_patch_user(self): - """A PATCH request should be sent and 404 errors ignored.""" - for side_effect in (None, self.response_error(404)): - with self.subTest(side_effect=side_effect): - await self.patch_user_helper(side_effect) - - async def test_sync_cog_patch_user_non_404(self): - """A PATCH request should be sent and the error raised if it's not a 404.""" - with self.assertRaises(ResponseCodeError): - await self.patch_user_helper(self.response_error(500)) - - -class SyncCogListenerTests(SyncCogTestCase): - """Tests for the listeners of the Sync cog.""" - - def setUp(self): - super().setUp() - self.cog.patch_user = mock.AsyncMock(spec_set=self.cog.patch_user) - - self.guild_id_patcher = mock.patch("bot.cogs.sync.cog.constants.Guild.id", 5) - self.guild_id = self.guild_id_patcher.start() - - self.guild = helpers.MockGuild(id=self.guild_id) - self.other_guild = helpers.MockGuild(id=0) - - def tearDown(self): - self.guild_id_patcher.stop() - - async def test_sync_cog_on_guild_role_create(self): - """A POST request should be sent with the new role's data.""" - self.assertTrue(self.cog.on_guild_role_create.__cog_listener__) - - role_data = { - "colour": 49, - "id": 777, - "name": "rolename", - "permissions": 8, - "position": 23, - } - role = helpers.MockRole(**role_data, guild=self.guild) - await self.cog.on_guild_role_create(role) - - self.bot.api_client.post.assert_called_once_with("bot/roles", json=role_data) - - async def test_sync_cog_on_guild_role_create_ignores_guilds(self): - """Events from other guilds should be ignored.""" - role = helpers.MockRole(guild=self.other_guild) - await self.cog.on_guild_role_create(role) - self.bot.api_client.post.assert_not_awaited() - - async def test_sync_cog_on_guild_role_delete(self): - """A DELETE request should be sent.""" - self.assertTrue(self.cog.on_guild_role_delete.__cog_listener__) - - role = helpers.MockRole(id=99, guild=self.guild) - await self.cog.on_guild_role_delete(role) - - self.bot.api_client.delete.assert_called_once_with("bot/roles/99") - - async def test_sync_cog_on_guild_role_delete_ignores_guilds(self): - """Events from other guilds should be ignored.""" - role = helpers.MockRole(guild=self.other_guild) - await self.cog.on_guild_role_delete(role) - self.bot.api_client.delete.assert_not_awaited() - - async def test_sync_cog_on_guild_role_update(self): - """A PUT request should be sent if the colour, name, permissions, or position changes.""" - self.assertTrue(self.cog.on_guild_role_update.__cog_listener__) - - role_data = { - "colour": 49, - "id": 777, - "name": "rolename", - "permissions": 8, - "position": 23, - } - subtests = ( - (True, ("colour", "name", "permissions", "position")), - (False, ("hoist", "mentionable")), - ) - - for should_put, attributes in subtests: - for attribute in attributes: - with self.subTest(should_put=should_put, changed_attribute=attribute): - self.bot.api_client.put.reset_mock() - - after_role_data = role_data.copy() - after_role_data[attribute] = 876 - - before_role = helpers.MockRole(**role_data, guild=self.guild) - after_role = helpers.MockRole(**after_role_data, guild=self.guild) - - await self.cog.on_guild_role_update(before_role, after_role) - - if should_put: - self.bot.api_client.put.assert_called_once_with( - f"bot/roles/{after_role.id}", - json=after_role_data - ) - else: - self.bot.api_client.put.assert_not_called() - - async def test_sync_cog_on_guild_role_update_ignores_guilds(self): - """Events from other guilds should be ignored.""" - role = helpers.MockRole(guild=self.other_guild) - await self.cog.on_guild_role_update(role, role) - self.bot.api_client.put.assert_not_awaited() - - async def test_sync_cog_on_member_remove(self): - """Member should be patched to set in_guild as False.""" - self.assertTrue(self.cog.on_member_remove.__cog_listener__) - - member = helpers.MockMember(guild=self.guild) - await self.cog.on_member_remove(member) - - self.cog.patch_user.assert_called_once_with( - member.id, - json={"in_guild": False} - ) - - async def test_sync_cog_on_member_remove_ignores_guilds(self): - """Events from other guilds should be ignored.""" - member = helpers.MockMember(guild=self.other_guild) - await self.cog.on_member_remove(member) - self.cog.patch_user.assert_not_awaited() - - async def test_sync_cog_on_member_update_roles(self): - """Members should be patched if their roles have changed.""" - self.assertTrue(self.cog.on_member_update.__cog_listener__) - - # Roles are intentionally unsorted. - before_roles = [helpers.MockRole(id=12), helpers.MockRole(id=30), helpers.MockRole(id=20)] - before_member = helpers.MockMember(roles=before_roles, guild=self.guild) - after_member = helpers.MockMember(roles=before_roles[1:], guild=self.guild) - - await self.cog.on_member_update(before_member, after_member) - - data = {"roles": sorted(role.id for role in after_member.roles)} - self.cog.patch_user.assert_called_once_with(after_member.id, json=data) - - async def test_sync_cog_on_member_update_other(self): - """Members should not be patched if other attributes have changed.""" - self.assertTrue(self.cog.on_member_update.__cog_listener__) - - subtests = ( - ("activities", discord.Game("Pong"), discord.Game("Frogger")), - ("nick", "old nick", "new nick"), - ("status", discord.Status.online, discord.Status.offline), - ) - - for attribute, old_value, new_value in subtests: - with self.subTest(attribute=attribute): - self.cog.patch_user.reset_mock() - - before_member = helpers.MockMember(**{attribute: old_value}, guild=self.guild) - after_member = helpers.MockMember(**{attribute: new_value}, guild=self.guild) - - await self.cog.on_member_update(before_member, after_member) - - self.cog.patch_user.assert_not_called() - - async def test_sync_cog_on_member_update_ignores_guilds(self): - """Events from other guilds should be ignored.""" - member = helpers.MockMember(guild=self.other_guild) - await self.cog.on_member_update(member, member) - self.cog.patch_user.assert_not_awaited() - - async def test_sync_cog_on_user_update(self): - """A user should be patched only if the name, discriminator, or avatar changes.""" - self.assertTrue(self.cog.on_user_update.__cog_listener__) - - before_data = { - "name": "old name", - "discriminator": "1234", - "bot": False, - } - - subtests = ( - (True, "name", "name", "new name", "new name"), - (True, "discriminator", "discriminator", "8765", 8765), - (False, "bot", "bot", True, True), - ) - - for should_patch, attribute, api_field, value, api_value in subtests: - with self.subTest(attribute=attribute): - self.cog.patch_user.reset_mock() - - after_data = before_data.copy() - after_data[attribute] = value - before_user = helpers.MockUser(**before_data) - after_user = helpers.MockUser(**after_data) - - await self.cog.on_user_update(before_user, after_user) - - if should_patch: - self.cog.patch_user.assert_called_once() - - # Don't care if *all* keys are present; only the changed one is required - call_args = self.cog.patch_user.call_args - self.assertEqual(call_args.args[0], after_user.id) - self.assertIn("json", call_args.kwargs) - - self.assertIn("ignore_404", call_args.kwargs) - self.assertTrue(call_args.kwargs["ignore_404"]) - - json = call_args.kwargs["json"] - self.assertIn(api_field, json) - self.assertEqual(json[api_field], api_value) - else: - self.cog.patch_user.assert_not_called() - - async def on_member_join_helper(self, side_effect: Exception) -> dict: - """ - Helper to set `side_effect` for on_member_join and assert a PUT request was sent. - - The request data for the mock member is returned. All exceptions will be re-raised. - """ - member = helpers.MockMember( - discriminator="1234", - roles=[helpers.MockRole(id=22), helpers.MockRole(id=12)], - guild=self.guild, - ) - - data = { - "discriminator": int(member.discriminator), - "id": member.id, - "in_guild": True, - "name": member.name, - "roles": sorted(role.id for role in member.roles) - } - - self.bot.api_client.put.reset_mock(side_effect=True) - self.bot.api_client.put.side_effect = side_effect - - try: - await self.cog.on_member_join(member) - except Exception: - raise - finally: - self.bot.api_client.put.assert_called_once_with( - f"bot/users/{member.id}", - json=data - ) - - return data - - async def test_sync_cog_on_member_join(self): - """Should PUT user's data or POST it if the user doesn't exist.""" - for side_effect in (None, self.response_error(404)): - with self.subTest(side_effect=side_effect): - self.bot.api_client.post.reset_mock() - data = await self.on_member_join_helper(side_effect) - - if side_effect: - self.bot.api_client.post.assert_called_once_with("bot/users", json=data) - else: - self.bot.api_client.post.assert_not_called() - - async def test_sync_cog_on_member_join_non_404(self): - """ResponseCodeError should be re-raised if status code isn't a 404.""" - with self.assertRaises(ResponseCodeError): - await self.on_member_join_helper(self.response_error(500)) - - self.bot.api_client.post.assert_not_called() - - async def test_sync_cog_on_member_join_ignores_guilds(self): - """Events from other guilds should be ignored.""" - member = helpers.MockMember(guild=self.other_guild) - await self.cog.on_member_join(member) - self.bot.api_client.post.assert_not_awaited() - self.bot.api_client.put.assert_not_awaited() - - -class SyncCogCommandTests(SyncCogTestCase, CommandTestCase): - """Tests for the commands in the Sync cog.""" - - async def test_sync_roles_command(self): - """sync() should be called on the RoleSyncer.""" - ctx = helpers.MockContext() - await self.cog.sync_roles_command.callback(self.cog, ctx) - - self.cog.role_syncer.sync.assert_called_once_with(ctx.guild, ctx) - - async def test_sync_users_command(self): - """sync() should be called on the UserSyncer.""" - ctx = helpers.MockContext() - await self.cog.sync_users_command.callback(self.cog, ctx) - - self.cog.user_syncer.sync.assert_called_once_with(ctx.guild, ctx) - - async def test_commands_require_admin(self): - """The sync commands should only run if the author has the administrator permission.""" - cmds = ( - self.cog.sync_group, - self.cog.sync_roles_command, - self.cog.sync_users_command, - ) - - for cmd in cmds: - with self.subTest(cmd=cmd): - await self.assertHasPermissionsCheck(cmd, {"administrator": True}) diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py deleted file mode 100644 index 888c49ca8..000000000 --- a/tests/bot/cogs/sync/test_roles.py +++ /dev/null @@ -1,157 +0,0 @@ -import unittest -from unittest import mock - -import discord - -from bot.cogs.backend.sync import RoleSyncer, _Diff, _Role -from tests import helpers - - -def fake_role(**kwargs): - """Fixture to return a dictionary representing a role with default values set.""" - kwargs.setdefault("id", 9) - kwargs.setdefault("name", "fake role") - kwargs.setdefault("colour", 7) - kwargs.setdefault("permissions", 0) - kwargs.setdefault("position", 55) - - return kwargs - - -class RoleSyncerDiffTests(unittest.IsolatedAsyncioTestCase): - """Tests for determining differences between roles in the DB and roles in the Guild cache.""" - - def setUp(self): - self.bot = helpers.MockBot() - self.syncer = RoleSyncer(self.bot) - - @staticmethod - def get_guild(*roles): - """Fixture to return a guild object with the given roles.""" - guild = helpers.MockGuild() - guild.roles = [] - - for role in roles: - mock_role = helpers.MockRole(**role) - mock_role.colour = discord.Colour(role["colour"]) - mock_role.permissions = discord.Permissions(role["permissions"]) - guild.roles.append(mock_role) - - return guild - - async def test_empty_diff_for_identical_roles(self): - """No differences should be found if the roles in the guild and DB are identical.""" - self.bot.api_client.get.return_value = [fake_role()] - guild = self.get_guild(fake_role()) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), set(), set()) - - self.assertEqual(actual_diff, expected_diff) - - async def test_diff_for_updated_roles(self): - """Only updated roles should be added to the 'updated' set of the diff.""" - updated_role = fake_role(id=41, name="new") - - self.bot.api_client.get.return_value = [fake_role(id=41, name="old"), fake_role()] - guild = self.get_guild(updated_role, fake_role()) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), {_Role(**updated_role)}, set()) - - self.assertEqual(actual_diff, expected_diff) - - async def test_diff_for_new_roles(self): - """Only new roles should be added to the 'created' set of the diff.""" - new_role = fake_role(id=41, name="new") - - self.bot.api_client.get.return_value = [fake_role()] - guild = self.get_guild(fake_role(), new_role) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = ({_Role(**new_role)}, set(), set()) - - self.assertEqual(actual_diff, expected_diff) - - async def test_diff_for_deleted_roles(self): - """Only deleted roles should be added to the 'deleted' set of the diff.""" - deleted_role = fake_role(id=61, name="deleted") - - self.bot.api_client.get.return_value = [fake_role(), deleted_role] - guild = self.get_guild(fake_role()) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), set(), {_Role(**deleted_role)}) - - self.assertEqual(actual_diff, expected_diff) - - async def test_diff_for_new_updated_and_deleted_roles(self): - """When roles are added, updated, and removed, all of them are returned properly.""" - new = fake_role(id=41, name="new") - updated = fake_role(id=71, name="updated") - deleted = fake_role(id=61, name="deleted") - - self.bot.api_client.get.return_value = [ - fake_role(), - fake_role(id=71, name="updated name"), - deleted, - ] - guild = self.get_guild(fake_role(), new, updated) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = ({_Role(**new)}, {_Role(**updated)}, {_Role(**deleted)}) - - self.assertEqual(actual_diff, expected_diff) - - -class RoleSyncerSyncTests(unittest.IsolatedAsyncioTestCase): - """Tests for the API requests that sync roles.""" - - def setUp(self): - self.bot = helpers.MockBot() - self.syncer = RoleSyncer(self.bot) - - async def test_sync_created_roles(self): - """Only POST requests should be made with the correct payload.""" - roles = [fake_role(id=111), fake_role(id=222)] - - role_tuples = {_Role(**role) for role in roles} - diff = _Diff(role_tuples, set(), set()) - await self.syncer._sync(diff) - - calls = [mock.call("bot/roles", json=role) for role in roles] - self.bot.api_client.post.assert_has_calls(calls, any_order=True) - self.assertEqual(self.bot.api_client.post.call_count, len(roles)) - - self.bot.api_client.put.assert_not_called() - self.bot.api_client.delete.assert_not_called() - - async def test_sync_updated_roles(self): - """Only PUT requests should be made with the correct payload.""" - roles = [fake_role(id=111), fake_role(id=222)] - - role_tuples = {_Role(**role) for role in roles} - diff = _Diff(set(), role_tuples, set()) - await self.syncer._sync(diff) - - calls = [mock.call(f"bot/roles/{role['id']}", json=role) for role in roles] - self.bot.api_client.put.assert_has_calls(calls, any_order=True) - self.assertEqual(self.bot.api_client.put.call_count, len(roles)) - - self.bot.api_client.post.assert_not_called() - self.bot.api_client.delete.assert_not_called() - - async def test_sync_deleted_roles(self): - """Only DELETE requests should be made with the correct payload.""" - roles = [fake_role(id=111), fake_role(id=222)] - - role_tuples = {_Role(**role) for role in roles} - diff = _Diff(set(), set(), role_tuples) - await self.syncer._sync(diff) - - calls = [mock.call(f"bot/roles/{role['id']}") for role in roles] - self.bot.api_client.delete.assert_has_calls(calls, any_order=True) - self.assertEqual(self.bot.api_client.delete.call_count, len(roles)) - - self.bot.api_client.post.assert_not_called() - self.bot.api_client.put.assert_not_called() diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py deleted file mode 100644 index 71f4b134c..000000000 --- a/tests/bot/cogs/sync/test_users.py +++ /dev/null @@ -1,158 +0,0 @@ -import unittest -from unittest import mock - -from bot.cogs.backend.sync import UserSyncer, _Diff, _User -from tests import helpers - - -def fake_user(**kwargs): - """Fixture to return a dictionary representing a user with default values set.""" - kwargs.setdefault("id", 43) - kwargs.setdefault("name", "bob the test man") - kwargs.setdefault("discriminator", 1337) - kwargs.setdefault("roles", (666,)) - kwargs.setdefault("in_guild", True) - - return kwargs - - -class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): - """Tests for determining differences between users in the DB and users in the Guild cache.""" - - def setUp(self): - self.bot = helpers.MockBot() - self.syncer = UserSyncer(self.bot) - - @staticmethod - def get_guild(*members): - """Fixture to return a guild object with the given members.""" - guild = helpers.MockGuild() - guild.members = [] - - for member in members: - member = member.copy() - del member["in_guild"] - - mock_member = helpers.MockMember(**member) - mock_member.roles = [helpers.MockRole(id=role_id) for role_id in member["roles"]] - - guild.members.append(mock_member) - - return guild - - async def test_empty_diff_for_no_users(self): - """When no users are given, an empty diff should be returned.""" - guild = self.get_guild() - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), set(), None) - - self.assertEqual(actual_diff, expected_diff) - - async def test_empty_diff_for_identical_users(self): - """No differences should be found if the users in the guild and DB are identical.""" - self.bot.api_client.get.return_value = [fake_user()] - guild = self.get_guild(fake_user()) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), set(), None) - - self.assertEqual(actual_diff, expected_diff) - - async def test_diff_for_updated_users(self): - """Only updated users should be added to the 'updated' set of the diff.""" - updated_user = fake_user(id=99, name="new") - - self.bot.api_client.get.return_value = [fake_user(id=99, name="old"), fake_user()] - guild = self.get_guild(updated_user, fake_user()) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), {_User(**updated_user)}, None) - - self.assertEqual(actual_diff, expected_diff) - - async def test_diff_for_new_users(self): - """Only new users should be added to the 'created' set of the diff.""" - new_user = fake_user(id=99, name="new") - - self.bot.api_client.get.return_value = [fake_user()] - guild = self.get_guild(fake_user(), new_user) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = ({_User(**new_user)}, set(), None) - - self.assertEqual(actual_diff, expected_diff) - - async def test_diff_sets_in_guild_false_for_leaving_users(self): - """When a user leaves the guild, the `in_guild` flag is updated to `False`.""" - leaving_user = fake_user(id=63, in_guild=False) - - self.bot.api_client.get.return_value = [fake_user(), fake_user(id=63)] - guild = self.get_guild(fake_user()) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), {_User(**leaving_user)}, None) - - self.assertEqual(actual_diff, expected_diff) - - async def test_diff_for_new_updated_and_leaving_users(self): - """When users are added, updated, and removed, all of them are returned properly.""" - new_user = fake_user(id=99, name="new") - updated_user = fake_user(id=55, name="updated") - leaving_user = fake_user(id=63, in_guild=False) - - self.bot.api_client.get.return_value = [fake_user(), fake_user(id=55), fake_user(id=63)] - guild = self.get_guild(fake_user(), new_user, updated_user) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = ({_User(**new_user)}, {_User(**updated_user), _User(**leaving_user)}, None) - - self.assertEqual(actual_diff, expected_diff) - - async def test_empty_diff_for_db_users_not_in_guild(self): - """When the DB knows a user the guild doesn't, no difference is found.""" - self.bot.api_client.get.return_value = [fake_user(), fake_user(id=63, in_guild=False)] - guild = self.get_guild(fake_user()) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), set(), None) - - self.assertEqual(actual_diff, expected_diff) - - -class UserSyncerSyncTests(unittest.IsolatedAsyncioTestCase): - """Tests for the API requests that sync users.""" - - def setUp(self): - self.bot = helpers.MockBot() - self.syncer = UserSyncer(self.bot) - - async def test_sync_created_users(self): - """Only POST requests should be made with the correct payload.""" - users = [fake_user(id=111), fake_user(id=222)] - - user_tuples = {_User(**user) for user in users} - diff = _Diff(user_tuples, set(), None) - await self.syncer._sync(diff) - - calls = [mock.call("bot/users", json=user) for user in users] - self.bot.api_client.post.assert_has_calls(calls, any_order=True) - self.assertEqual(self.bot.api_client.post.call_count, len(users)) - - self.bot.api_client.put.assert_not_called() - self.bot.api_client.delete.assert_not_called() - - async def test_sync_updated_users(self): - """Only PUT requests should be made with the correct payload.""" - users = [fake_user(id=111), fake_user(id=222)] - - user_tuples = {_User(**user) for user in users} - diff = _Diff(set(), user_tuples, None) - await self.syncer._sync(diff) - - calls = [mock.call(f"bot/users/{user['id']}", json=user) for user in users] - self.bot.api_client.put.assert_has_calls(calls, any_order=True) - self.assertEqual(self.bot.api_client.put.call_count, len(users)) - - self.bot.api_client.post.assert_not_called() - self.bot.api_client.delete.assert_not_called() diff --git a/tests/bot/cogs/test_antimalware.py b/tests/bot/cogs/test_antimalware.py deleted file mode 100644 index b00211f47..000000000 --- a/tests/bot/cogs/test_antimalware.py +++ /dev/null @@ -1,165 +0,0 @@ -import unittest -from unittest.mock import AsyncMock, Mock - -from discord import NotFound - -from bot.cogs.filters import antimalware -from bot.constants import Channels, STAFF_ROLES -from tests.helpers import MockAttachment, MockBot, MockMessage, MockRole - - -class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): - """Test the AntiMalware cog.""" - - def setUp(self): - """Sets up fresh objects for each test.""" - self.bot = MockBot() - self.bot.filter_list_cache = { - "FILE_FORMAT.True": { - ".first": {}, - ".second": {}, - ".third": {}, - } - } - self.cog = antimalware.AntiMalware(self.bot) - self.message = MockMessage() - self.whitelist = [".first", ".second", ".third"] - - async def test_message_with_allowed_attachment(self): - """Messages with allowed extensions should not be deleted""" - attachment = MockAttachment(filename="python.first") - self.message.attachments = [attachment] - - await self.cog.on_message(self.message) - self.message.delete.assert_not_called() - - async def test_message_without_attachment(self): - """Messages without attachments should result in no action.""" - await self.cog.on_message(self.message) - self.message.delete.assert_not_called() - - async def test_direct_message_with_attachment(self): - """Direct messages should have no action taken.""" - attachment = MockAttachment(filename="python.disallowed") - self.message.attachments = [attachment] - self.message.guild = None - - await self.cog.on_message(self.message) - - self.message.delete.assert_not_called() - - async def test_message_with_illegal_extension_gets_deleted(self): - """A message containing an illegal extension should send an embed.""" - attachment = MockAttachment(filename="python.disallowed") - self.message.attachments = [attachment] - - await self.cog.on_message(self.message) - - self.message.delete.assert_called_once() - - async def test_message_send_by_staff(self): - """A message send by a member of staff should be ignored.""" - staff_role = MockRole(id=STAFF_ROLES[0]) - self.message.author.roles.append(staff_role) - attachment = MockAttachment(filename="python.disallowed") - self.message.attachments = [attachment] - - await self.cog.on_message(self.message) - - self.message.delete.assert_not_called() - - async def test_python_file_redirect_embed_description(self): - """A message containing a .py file should result in an embed redirecting the user to our paste site""" - attachment = MockAttachment(filename="python.py") - self.message.attachments = [attachment] - self.message.channel.send = AsyncMock() - - await self.cog.on_message(self.message) - self.message.channel.send.assert_called_once() - args, kwargs = self.message.channel.send.call_args - embed = kwargs.pop("embed") - - self.assertEqual(embed.description, antimalware.PY_EMBED_DESCRIPTION) - - async def test_txt_file_redirect_embed_description(self): - """A message containing a .txt file should result in the correct embed.""" - attachment = MockAttachment(filename="python.txt") - self.message.attachments = [attachment] - self.message.channel.send = AsyncMock() - antimalware.TXT_EMBED_DESCRIPTION = Mock() - antimalware.TXT_EMBED_DESCRIPTION.format.return_value = "test" - - await self.cog.on_message(self.message) - self.message.channel.send.assert_called_once() - args, kwargs = self.message.channel.send.call_args - embed = kwargs.pop("embed") - cmd_channel = self.bot.get_channel(Channels.bot_commands) - - self.assertEqual(embed.description, antimalware.TXT_EMBED_DESCRIPTION.format.return_value) - antimalware.TXT_EMBED_DESCRIPTION.format.assert_called_with(cmd_channel_mention=cmd_channel.mention) - - async def test_other_disallowed_extension_embed_description(self): - """Test the description for a non .py/.txt disallowed extension.""" - attachment = MockAttachment(filename="python.disallowed") - self.message.attachments = [attachment] - self.message.channel.send = AsyncMock() - antimalware.DISALLOWED_EMBED_DESCRIPTION = Mock() - antimalware.DISALLOWED_EMBED_DESCRIPTION.format.return_value = "test" - - await self.cog.on_message(self.message) - self.message.channel.send.assert_called_once() - args, kwargs = self.message.channel.send.call_args - embed = kwargs.pop("embed") - meta_channel = self.bot.get_channel(Channels.meta) - - self.assertEqual(embed.description, antimalware.DISALLOWED_EMBED_DESCRIPTION.format.return_value) - antimalware.DISALLOWED_EMBED_DESCRIPTION.format.assert_called_with( - joined_whitelist=", ".join(self.whitelist), - blocked_extensions_str=".disallowed", - meta_channel_mention=meta_channel.mention - ) - - async def test_removing_deleted_message_logs(self): - """Removing an already deleted message logs the correct message""" - attachment = MockAttachment(filename="python.disallowed") - self.message.attachments = [attachment] - self.message.delete = AsyncMock(side_effect=NotFound(response=Mock(status=""), message="")) - - with self.assertLogs(logger=antimalware.log, level="INFO"): - await self.cog.on_message(self.message) - self.message.delete.assert_called_once() - - async def test_message_with_illegal_attachment_logs(self): - """Deleting a message with an illegal attachment should result in a log.""" - attachment = MockAttachment(filename="python.disallowed") - self.message.attachments = [attachment] - - with self.assertLogs(logger=antimalware.log, level="INFO"): - await self.cog.on_message(self.message) - - async def test_get_disallowed_extensions(self): - """The return value should include all non-whitelisted extensions.""" - test_values = ( - ([], []), - (self.whitelist, []), - ([".first"], []), - ([".first", ".disallowed"], [".disallowed"]), - ([".disallowed"], [".disallowed"]), - ([".disallowed", ".illegal"], [".disallowed", ".illegal"]), - ) - - for extensions, expected_disallowed_extensions in test_values: - with self.subTest(extensions=extensions, expected_disallowed_extensions=expected_disallowed_extensions): - self.message.attachments = [MockAttachment(filename=f"filename{extension}") for extension in extensions] - disallowed_extensions = self.cog._get_disallowed_extensions(self.message) - self.assertCountEqual(disallowed_extensions, expected_disallowed_extensions) - - -class AntiMalwareSetupTests(unittest.TestCase): - """Tests setup of the `AntiMalware` cog.""" - - def test_setup(self): - """Setup of the extension should call add_cog.""" - bot = MockBot() - antimalware.setup(bot) - bot.add_cog.assert_called_once() diff --git a/tests/bot/cogs/test_antispam.py b/tests/bot/cogs/test_antispam.py deleted file mode 100644 index 8a3d8d02e..000000000 --- a/tests/bot/cogs/test_antispam.py +++ /dev/null @@ -1,35 +0,0 @@ -import unittest - -from bot.cogs.filters import antispam - - -class AntispamConfigurationValidationTests(unittest.TestCase): - """Tests validation of the antispam cog configuration.""" - - def test_default_antispam_config_is_valid(self): - """The default antispam configuration is valid.""" - validation_errors = antispam.validate_config() - self.assertEqual(validation_errors, {}) - - def test_unknown_rule_returns_error(self): - """Configuring an unknown rule returns an error.""" - self.assertEqual( - antispam.validate_config({'invalid-rule': {}}), - {'invalid-rule': "`invalid-rule` is not recognized as an antispam rule."} - ) - - def test_missing_keys_returns_error(self): - """Not configuring required keys returns an error.""" - keys = (('interval', 'max'), ('max', 'interval')) - for configured_key, unconfigured_key in keys: - with self.subTest( - configured_key=configured_key, - unconfigured_key=unconfigured_key - ): - config = {'burst': {configured_key: 10}} - error = f"Key `{unconfigured_key}` is required but not set for rule `burst`" - - self.assertEqual( - antispam.validate_config(config), - {'burst': error} - ) diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py deleted file mode 100644 index 305a2bad9..000000000 --- a/tests/bot/cogs/test_information.py +++ /dev/null @@ -1,584 +0,0 @@ -import asyncio -import textwrap -import unittest -import unittest.mock - -import discord - -from bot import constants -from bot.cogs.info import information -from bot.utils.checks import InWhitelistCheckFailure -from tests import helpers - -COG_PATH = "bot.cogs.information.Information" - - -class InformationCogTests(unittest.TestCase): - """Tests the Information cog.""" - - @classmethod - def setUpClass(cls): - cls.moderator_role = helpers.MockRole(name="Moderator", id=constants.Roles.moderators) - - def setUp(self): - """Sets up fresh objects for each test.""" - self.bot = helpers.MockBot() - - self.cog = information.Information(self.bot) - - self.ctx = helpers.MockContext() - self.ctx.author.roles.append(self.moderator_role) - - def test_roles_command_command(self): - """Test if the `role_info` command correctly returns the `moderator_role`.""" - self.ctx.guild.roles.append(self.moderator_role) - - self.cog.roles_info.can_run = unittest.mock.AsyncMock() - self.cog.roles_info.can_run.return_value = True - - coroutine = self.cog.roles_info.callback(self.cog, self.ctx) - - self.assertIsNone(asyncio.run(coroutine)) - self.ctx.send.assert_called_once() - - _, kwargs = self.ctx.send.call_args - embed = kwargs.pop('embed') - - self.assertEqual(embed.title, "Role information (Total 1 role)") - self.assertEqual(embed.colour, discord.Colour.blurple()) - self.assertEqual(embed.description, f"\n`{self.moderator_role.id}` - {self.moderator_role.mention}\n") - - def test_role_info_command(self): - """Tests the `role info` command.""" - dummy_role = helpers.MockRole( - name="Dummy", - id=112233445566778899, - colour=discord.Colour.blurple(), - position=10, - members=[self.ctx.author], - permissions=discord.Permissions(0) - ) - - admin_role = helpers.MockRole( - name="Admins", - id=998877665544332211, - colour=discord.Colour.red(), - position=3, - members=[self.ctx.author], - permissions=discord.Permissions(0), - ) - - self.ctx.guild.roles.append([dummy_role, admin_role]) - - self.cog.role_info.can_run = unittest.mock.AsyncMock() - self.cog.role_info.can_run.return_value = True - - coroutine = self.cog.role_info.callback(self.cog, self.ctx, dummy_role, admin_role) - - self.assertIsNone(asyncio.run(coroutine)) - - self.assertEqual(self.ctx.send.call_count, 2) - - (_, dummy_kwargs), (_, admin_kwargs) = self.ctx.send.call_args_list - - dummy_embed = dummy_kwargs["embed"] - admin_embed = admin_kwargs["embed"] - - self.assertEqual(dummy_embed.title, "Dummy info") - self.assertEqual(dummy_embed.colour, discord.Colour.blurple()) - - self.assertEqual(dummy_embed.fields[0].value, str(dummy_role.id)) - self.assertEqual(dummy_embed.fields[1].value, f"#{dummy_role.colour.value:0>6x}") - self.assertEqual(dummy_embed.fields[2].value, "0.63 0.48 218") - self.assertEqual(dummy_embed.fields[3].value, "1") - self.assertEqual(dummy_embed.fields[4].value, "10") - self.assertEqual(dummy_embed.fields[5].value, "0") - - self.assertEqual(admin_embed.title, "Admins info") - self.assertEqual(admin_embed.colour, discord.Colour.red()) - - @unittest.mock.patch('bot.cogs.information.time_since') - def test_server_info_command(self, time_since_patch): - time_since_patch.return_value = '2 days ago' - - self.ctx.guild = helpers.MockGuild( - features=('lemons', 'apples'), - region="The Moon", - roles=[self.moderator_role], - channels=[ - discord.TextChannel( - state={}, - guild=self.ctx.guild, - data={'id': 42, 'name': 'lemons-offering', 'position': 22, 'type': 'text'} - ), - discord.CategoryChannel( - state={}, - guild=self.ctx.guild, - data={'id': 5125, 'name': 'the-lemon-collection', 'position': 22, 'type': 'category'} - ), - discord.VoiceChannel( - state={}, - guild=self.ctx.guild, - data={'id': 15290, 'name': 'listen-to-lemon', 'position': 22, 'type': 'voice'} - ) - ], - members=[ - *(helpers.MockMember(status=discord.Status.online) for _ in range(2)), - *(helpers.MockMember(status=discord.Status.idle) for _ in range(1)), - *(helpers.MockMember(status=discord.Status.dnd) for _ in range(4)), - *(helpers.MockMember(status=discord.Status.offline) for _ in range(3)), - ], - member_count=1_234, - icon_url='a-lemon.jpg', - ) - - coroutine = self.cog.server_info.callback(self.cog, self.ctx) - self.assertIsNone(asyncio.run(coroutine)) - - time_since_patch.assert_called_once_with(self.ctx.guild.created_at, precision='days') - _, kwargs = self.ctx.send.call_args - embed = kwargs.pop('embed') - self.assertEqual(embed.colour, discord.Colour.blurple()) - self.assertEqual( - embed.description, - textwrap.dedent( - f""" - **Server information** - Created: {time_since_patch.return_value} - Voice region: {self.ctx.guild.region} - Features: {', '.join(self.ctx.guild.features)} - - **Channel counts** - Category channels: 1 - Text channels: 1 - Voice channels: 1 - Staff channels: 0 - - **Member counts** - Members: {self.ctx.guild.member_count:,} - Staff members: 0 - Roles: {len(self.ctx.guild.roles)} - - **Member statuses** - {constants.Emojis.status_online} 2 - {constants.Emojis.status_idle} 1 - {constants.Emojis.status_dnd} 4 - {constants.Emojis.status_offline} 3 - """ - ) - ) - self.assertEqual(embed.thumbnail.url, 'a-lemon.jpg') - - -class UserInfractionHelperMethodTests(unittest.TestCase): - """Tests for the helper methods of the `!user` command.""" - - def setUp(self): - """Common set-up steps done before for each test.""" - self.bot = helpers.MockBot() - self.bot.api_client.get = unittest.mock.AsyncMock() - self.cog = information.Information(self.bot) - self.member = helpers.MockMember(id=1234) - - def test_user_command_helper_method_get_requests(self): - """The helper methods should form the correct get requests.""" - test_values = ( - { - "helper_method": self.cog.basic_user_infraction_counts, - "expected_args": ("bot/infractions", {'hidden': 'False', 'user__id': str(self.member.id)}), - }, - { - "helper_method": self.cog.expanded_user_infraction_counts, - "expected_args": ("bot/infractions", {'user__id': str(self.member.id)}), - }, - { - "helper_method": self.cog.user_nomination_counts, - "expected_args": ("bot/nominations", {'user__id': str(self.member.id)}), - }, - ) - - for test_value in test_values: - helper_method = test_value["helper_method"] - endpoint, params = test_value["expected_args"] - - with self.subTest(method=helper_method, endpoint=endpoint, params=params): - asyncio.run(helper_method(self.member)) - self.bot.api_client.get.assert_called_once_with(endpoint, params=params) - self.bot.api_client.get.reset_mock() - - def _method_subtests(self, method, test_values, default_header): - """Helper method that runs the subtests for the different helper methods.""" - for test_value in test_values: - api_response = test_value["api response"] - expected_lines = test_value["expected_lines"] - - with self.subTest(method=method, api_response=api_response, expected_lines=expected_lines): - self.bot.api_client.get.return_value = api_response - - expected_output = "\n".join(default_header + expected_lines) - actual_output = asyncio.run(method(self.member)) - - self.assertEqual(expected_output, actual_output) - - def test_basic_user_infraction_counts_returns_correct_strings(self): - """The method should correctly list both the total and active number of non-hidden infractions.""" - test_values = ( - # No infractions means zero counts - { - "api response": [], - "expected_lines": ["Total: 0", "Active: 0"], - }, - # Simple, single-infraction dictionaries - { - "api response": [{"type": "ban", "active": True}], - "expected_lines": ["Total: 1", "Active: 1"], - }, - { - "api response": [{"type": "ban", "active": False}], - "expected_lines": ["Total: 1", "Active: 0"], - }, - # Multiple infractions with various `active` status - { - "api response": [ - {"type": "ban", "active": True}, - {"type": "kick", "active": False}, - {"type": "ban", "active": True}, - {"type": "ban", "active": False}, - ], - "expected_lines": ["Total: 4", "Active: 2"], - }, - ) - - header = ["**Infractions**"] - - self._method_subtests(self.cog.basic_user_infraction_counts, test_values, header) - - def test_expanded_user_infraction_counts_returns_correct_strings(self): - """The method should correctly list the total and active number of all infractions split by infraction type.""" - test_values = ( - { - "api response": [], - "expected_lines": ["This user has never received an infraction."], - }, - # Shows non-hidden inactive infraction as expected - { - "api response": [{"type": "kick", "active": False, "hidden": False}], - "expected_lines": ["Kicks: 1"], - }, - # Shows non-hidden active infraction as expected - { - "api response": [{"type": "mute", "active": True, "hidden": False}], - "expected_lines": ["Mutes: 1 (1 active)"], - }, - # Shows hidden inactive infraction as expected - { - "api response": [{"type": "superstar", "active": False, "hidden": True}], - "expected_lines": ["Superstars: 1"], - }, - # Shows hidden active infraction as expected - { - "api response": [{"type": "ban", "active": True, "hidden": True}], - "expected_lines": ["Bans: 1 (1 active)"], - }, - # Correctly displays tally of multiple infractions of mixed properties in alphabetical order - { - "api response": [ - {"type": "kick", "active": False, "hidden": True}, - {"type": "ban", "active": True, "hidden": True}, - {"type": "superstar", "active": True, "hidden": True}, - {"type": "mute", "active": True, "hidden": True}, - {"type": "ban", "active": False, "hidden": False}, - {"type": "note", "active": False, "hidden": True}, - {"type": "note", "active": False, "hidden": True}, - {"type": "warn", "active": False, "hidden": False}, - {"type": "note", "active": False, "hidden": True}, - ], - "expected_lines": [ - "Bans: 2 (1 active)", - "Kicks: 1", - "Mutes: 1 (1 active)", - "Notes: 3", - "Superstars: 1 (1 active)", - "Warns: 1", - ], - }, - ) - - header = ["**Infractions**"] - - self._method_subtests(self.cog.expanded_user_infraction_counts, test_values, header) - - def test_user_nomination_counts_returns_correct_strings(self): - """The method should list the number of active and historical nominations for the user.""" - test_values = ( - { - "api response": [], - "expected_lines": ["This user has never been nominated."], - }, - { - "api response": [{'active': True}], - "expected_lines": ["This user is **currently** nominated (1 nomination in total)."], - }, - { - "api response": [{'active': True}, {'active': False}], - "expected_lines": ["This user is **currently** nominated (2 nominations in total)."], - }, - { - "api response": [{'active': False}], - "expected_lines": ["This user has 1 historical nomination, but is currently not nominated."], - }, - { - "api response": [{'active': False}, {'active': False}], - "expected_lines": ["This user has 2 historical nominations, but is currently not nominated."], - }, - - ) - - header = ["**Nominations**"] - - self._method_subtests(self.cog.user_nomination_counts, test_values, header) - - -@unittest.mock.patch("bot.cogs.information.time_since", new=unittest.mock.MagicMock(return_value="1 year ago")) -@unittest.mock.patch("bot.cogs.information.constants.MODERATION_CHANNELS", new=[50]) -class UserEmbedTests(unittest.TestCase): - """Tests for the creation of the `!user` embed.""" - - def setUp(self): - """Common set-up steps done before for each test.""" - self.bot = helpers.MockBot() - self.bot.api_client.get = unittest.mock.AsyncMock() - self.cog = information.Information(self.bot) - - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) - def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self): - """The embed should use the string representation of the user if they don't have a nick.""" - ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) - user = helpers.MockMember() - user.nick = None - user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") - - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) - - self.assertEqual(embed.title, "Mr. Hemlock") - - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) - def test_create_user_embed_uses_nick_in_title_if_available(self): - """The embed should use the nick if it's available.""" - ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) - user = helpers.MockMember() - user.nick = "Cat lover" - user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") - - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) - - self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)") - - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) - def test_create_user_embed_ignores_everyone_role(self): - """Created `!user` embeds should not contain mention of the @everyone-role.""" - ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) - admins_role = helpers.MockRole(name='Admins') - admins_role.colour = 100 - - # A `MockMember` has the @Everyone role by default; we add the Admins to that. - user = helpers.MockMember(roles=[admins_role], top_role=admins_role) - - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) - - self.assertIn("&Admins", embed.description) - self.assertNotIn("&Everyone", embed.description) - - @unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=unittest.mock.AsyncMock) - @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=unittest.mock.AsyncMock) - def test_create_user_embed_expanded_information_in_moderation_channels(self, nomination_counts, infraction_counts): - """The embed should contain expanded infractions and nomination info in mod channels.""" - ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=50)) - - moderators_role = helpers.MockRole(name='Moderators') - moderators_role.colour = 100 - - infraction_counts.return_value = "expanded infractions info" - nomination_counts.return_value = "nomination info" - - user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) - - infraction_counts.assert_called_once_with(user) - nomination_counts.assert_called_once_with(user) - - self.assertEqual( - textwrap.dedent(f""" - **User Information** - Created: {"1 year ago"} - Profile: {user.mention} - ID: {user.id} - - **Member Information** - Joined: {"1 year ago"} - Roles: &Moderators - - expanded infractions info - - nomination info - """).strip(), - embed.description - ) - - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=unittest.mock.AsyncMock) - def test_create_user_embed_basic_information_outside_of_moderation_channels(self, infraction_counts): - """The embed should contain only basic infraction data outside of mod channels.""" - ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=100)) - - moderators_role = helpers.MockRole(name='Moderators') - moderators_role.colour = 100 - - infraction_counts.return_value = "basic infractions info" - - user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) - - infraction_counts.assert_called_once_with(user) - - self.assertEqual( - textwrap.dedent(f""" - **User Information** - Created: {"1 year ago"} - Profile: {user.mention} - ID: {user.id} - - **Member Information** - Joined: {"1 year ago"} - Roles: &Moderators - - basic infractions info - """).strip(), - embed.description - ) - - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) - def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self): - """The embed should be created with the colour of the top role, if a top role is available.""" - ctx = helpers.MockContext() - - moderators_role = helpers.MockRole(name='Moderators') - moderators_role.colour = 100 - - user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) - - self.assertEqual(embed.colour, discord.Colour(moderators_role.colour)) - - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) - def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self): - """The embed should be created with a blurple colour if the user has no assigned roles.""" - ctx = helpers.MockContext() - - user = helpers.MockMember(id=217) - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) - - self.assertEqual(embed.colour, discord.Colour.blurple()) - - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) - def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self): - """The embed thumbnail should be set to the user's avatar in `png` format.""" - ctx = helpers.MockContext() - - user = helpers.MockMember(id=217) - user.avatar_url_as.return_value = "avatar url" - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) - - user.avatar_url_as.assert_called_once_with(static_format="png") - self.assertEqual(embed.thumbnail.url, "avatar url") - - -@unittest.mock.patch("bot.cogs.information.constants") -class UserCommandTests(unittest.TestCase): - """Tests for the `!user` command.""" - - def setUp(self): - """Set up steps executed before each test is run.""" - self.bot = helpers.MockBot() - self.cog = information.Information(self.bot) - - self.moderator_role = helpers.MockRole(name="Moderators", id=2, position=10) - self.flautist_role = helpers.MockRole(name="Flautists", id=3, position=2) - self.bassist_role = helpers.MockRole(name="Bassists", id=4, position=3) - - self.author = helpers.MockMember(id=1, name="syntaxaire") - self.moderator = helpers.MockMember(id=2, name="riffautae", roles=[self.moderator_role]) - self.target = helpers.MockMember(id=3, name="__fluzz__") - - def test_regular_member_cannot_target_another_member(self, constants): - """A regular user should not be able to use `!user` targeting another user.""" - constants.MODERATION_ROLES = [self.moderator_role.id] - - ctx = helpers.MockContext(author=self.author) - - asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target)) - - ctx.send.assert_called_once_with("You may not use this command on users other than yourself.") - - def test_regular_member_cannot_use_command_outside_of_bot_commands(self, constants): - """A regular user should not be able to use this command outside of bot-commands.""" - constants.MODERATION_ROLES = [self.moderator_role.id] - constants.STAFF_ROLES = [self.moderator_role.id] - constants.Channels.bot_commands = 50 - - ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=100)) - - msg = "Sorry, but you may only use this command within <#50>." - with self.assertRaises(InWhitelistCheckFailure, msg=msg): - asyncio.run(self.cog.user_info.callback(self.cog, ctx)) - - @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock) - def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants): - """A regular user should be allowed to use `!user` targeting themselves in bot-commands.""" - constants.STAFF_ROLES = [self.moderator_role.id] - constants.Channels.bot_commands = 50 - - ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) - - asyncio.run(self.cog.user_info.callback(self.cog, ctx)) - - create_embed.assert_called_once_with(ctx, self.author) - ctx.send.assert_called_once() - - @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock) - def test_regular_user_can_explicitly_target_themselves(self, create_embed, constants): - """A user should target itself with `!user` when a `user` argument was not provided.""" - constants.STAFF_ROLES = [self.moderator_role.id] - constants.Channels.bot_commands = 50 - - ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) - - asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.author)) - - create_embed.assert_called_once_with(ctx, self.author) - ctx.send.assert_called_once() - - @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock) - def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants): - """Staff members should be able to bypass the bot-commands channel restriction.""" - constants.STAFF_ROLES = [self.moderator_role.id] - constants.Channels.bot_commands = 50 - - ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=200)) - - asyncio.run(self.cog.user_info.callback(self.cog, ctx)) - - create_embed.assert_called_once_with(ctx, self.moderator) - ctx.send.assert_called_once() - - @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock) - def test_moderators_can_target_another_member(self, create_embed, constants): - """A moderator should be able to use `!user` targeting another user.""" - constants.MODERATION_ROLES = [self.moderator_role.id] - constants.STAFF_ROLES = [self.moderator_role.id] - - ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=50)) - - asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target)) - - create_embed.assert_called_once_with(ctx, self.target) - ctx.send.assert_called_once() diff --git a/tests/bot/cogs/test_jams.py b/tests/bot/cogs/test_jams.py deleted file mode 100644 index b4ad8535f..000000000 --- a/tests/bot/cogs/test_jams.py +++ /dev/null @@ -1,173 +0,0 @@ -import unittest -from unittest.mock import AsyncMock, MagicMock, create_autospec - -from discord import CategoryChannel - -from bot.cogs import jams -from bot.constants import Roles -from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockTextChannel - - -def get_mock_category(channel_count: int, name: str) -> CategoryChannel: - """Return a mocked code jam category.""" - category = create_autospec(CategoryChannel, spec_set=True, instance=True) - category.name = name - category.channels = [MockTextChannel() for _ in range(channel_count)] - - return category - - -class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): - """Tests for `createteam` command.""" - - def setUp(self): - self.bot = MockBot() - self.admin_role = MockRole(name="Admins", id=Roles.admins) - self.command_user = MockMember([self.admin_role]) - self.guild = MockGuild([self.admin_role]) - self.ctx = MockContext(bot=self.bot, author=self.command_user, guild=self.guild) - self.cog = jams.CodeJams(self.bot) - - async def test_too_small_amount_of_team_members_passed(self): - """Should `ctx.send` and exit early when too small amount of members.""" - for case in (1, 2): - with self.subTest(amount_of_members=case): - self.cog.create_channels = AsyncMock() - self.cog.add_roles = AsyncMock() - - self.ctx.reset_mock() - members = (MockMember() for _ in range(case)) - await self.cog.createteam(self.cog, self.ctx, "foo", members) - - self.ctx.send.assert_awaited_once() - self.cog.create_channels.assert_not_awaited() - self.cog.add_roles.assert_not_awaited() - - async def test_duplicate_members_provided(self): - """Should `ctx.send` and exit early because duplicate members provided and total there is only 1 member.""" - self.cog.create_channels = AsyncMock() - self.cog.add_roles = AsyncMock() - - member = MockMember() - await self.cog.createteam(self.cog, self.ctx, "foo", (member for _ in range(5))) - - self.ctx.send.assert_awaited_once() - self.cog.create_channels.assert_not_awaited() - self.cog.add_roles.assert_not_awaited() - - async def test_result_sending(self): - """Should call `ctx.send` when everything goes right.""" - self.cog.create_channels = AsyncMock() - self.cog.add_roles = AsyncMock() - - members = [MockMember() for _ in range(5)] - await self.cog.createteam(self.cog, self.ctx, "foo", members) - - self.cog.create_channels.assert_awaited_once() - self.cog.add_roles.assert_awaited_once() - self.ctx.send.assert_awaited_once() - - async def test_category_doesnt_exist(self): - """Should create a new code jam category.""" - subtests = ( - [], - [get_mock_category(jams.MAX_CHANNELS - 1, jams.CATEGORY_NAME)], - [get_mock_category(jams.MAX_CHANNELS - 2, "other")], - ) - - for categories in subtests: - self.guild.reset_mock() - self.guild.categories = categories - - with self.subTest(categories=categories): - actual_category = await self.cog.get_category(self.guild) - - self.guild.create_category_channel.assert_awaited_once() - category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"] - - self.assertFalse(category_overwrites[self.guild.default_role].read_messages) - self.assertTrue(category_overwrites[self.guild.me].read_messages) - self.assertEqual(self.guild.create_category_channel.return_value, actual_category) - - async def test_category_channel_exist(self): - """Should not try to create category channel.""" - expected_category = get_mock_category(jams.MAX_CHANNELS - 2, jams.CATEGORY_NAME) - self.guild.categories = [ - get_mock_category(jams.MAX_CHANNELS - 2, "other"), - expected_category, - get_mock_category(0, jams.CATEGORY_NAME), - ] - - actual_category = await self.cog.get_category(self.guild) - self.assertEqual(expected_category, actual_category) - - async def test_channel_overwrites(self): - """Should have correct permission overwrites for users and roles.""" - leader = MockMember() - members = [leader] + [MockMember() for _ in range(4)] - overwrites = self.cog.get_overwrites(members, self.guild) - - # Leader permission overwrites - self.assertTrue(overwrites[leader].manage_messages) - self.assertTrue(overwrites[leader].read_messages) - self.assertTrue(overwrites[leader].manage_webhooks) - self.assertTrue(overwrites[leader].connect) - - # Other members permission overwrites - for member in members[1:]: - self.assertTrue(overwrites[member].read_messages) - self.assertTrue(overwrites[member].connect) - - # Everyone and verified role overwrite - self.assertFalse(overwrites[self.guild.default_role].read_messages) - self.assertFalse(overwrites[self.guild.default_role].connect) - self.assertFalse(overwrites[self.guild.get_role(Roles.verified)].read_messages) - self.assertFalse(overwrites[self.guild.get_role(Roles.verified)].connect) - - async def test_team_channels_creation(self): - """Should create new voice and text channel for team.""" - members = [MockMember() for _ in range(5)] - - self.cog.get_overwrites = MagicMock() - self.cog.get_category = AsyncMock() - self.ctx.guild.create_text_channel.return_value = MockTextChannel(mention="foobar-channel") - actual = await self.cog.create_channels(self.guild, "my-team", members) - - self.assertEqual("foobar-channel", actual) - self.cog.get_overwrites.assert_called_once_with(members, self.guild) - self.cog.get_category.assert_awaited_once_with(self.guild) - - self.guild.create_text_channel.assert_awaited_once_with( - "my-team", - overwrites=self.cog.get_overwrites.return_value, - category=self.cog.get_category.return_value - ) - self.guild.create_voice_channel.assert_awaited_once_with( - "My Team", - overwrites=self.cog.get_overwrites.return_value, - category=self.cog.get_category.return_value - ) - - async def test_jam_roles_adding(self): - """Should add team leader role to leader and jam role to every team member.""" - leader_role = MockRole(name="Team Leader") - jam_role = MockRole(name="Jammer") - self.guild.get_role.side_effect = [leader_role, jam_role] - - leader = MockMember() - members = [leader] + [MockMember() for _ in range(4)] - await self.cog.add_roles(self.guild, members) - - leader.add_roles.assert_any_await(leader_role) - for member in members: - member.add_roles.assert_any_await(jam_role) - - -class CodeJamSetup(unittest.TestCase): - """Test for `setup` function of `CodeJam` cog.""" - - def test_setup(self): - """Should call `bot.add_cog`.""" - bot = MockBot() - jams.setup(bot) - bot.add_cog.assert_called_once() diff --git a/tests/bot/cogs/test_logging.py b/tests/bot/cogs/test_logging.py deleted file mode 100644 index 8a18fdcd6..000000000 --- a/tests/bot/cogs/test_logging.py +++ /dev/null @@ -1,32 +0,0 @@ -import unittest -from unittest.mock import patch - -from bot import constants -from bot.cogs.logging import Logging -from tests.helpers import MockBot, MockTextChannel - - -class LoggingTests(unittest.IsolatedAsyncioTestCase): - """Test cases for connected login.""" - - def setUp(self): - self.bot = MockBot() - self.cog = Logging(self.bot) - self.dev_log = MockTextChannel(id=1234, name="dev-log") - - @patch("bot.cogs.logging.DEBUG_MODE", False) - async def test_debug_mode_false(self): - """Should send connected message to dev-log.""" - self.bot.get_channel.return_value = self.dev_log - - await self.cog.startup_greeting() - self.bot.wait_until_guild_available.assert_awaited_once_with() - self.bot.get_channel.assert_called_once_with(constants.Channels.dev_log) - self.dev_log.send.assert_awaited_once() - - @patch("bot.cogs.logging.DEBUG_MODE", True) - async def test_debug_mode_true(self): - """Should not send anything to dev-log.""" - await self.cog.startup_greeting() - self.bot.wait_until_guild_available.assert_awaited_once_with() - self.bot.get_channel.assert_not_called() diff --git a/tests/bot/cogs/test_security.py b/tests/bot/cogs/test_security.py deleted file mode 100644 index 82679f69c..000000000 --- a/tests/bot/cogs/test_security.py +++ /dev/null @@ -1,54 +0,0 @@ -import unittest -from unittest.mock import MagicMock - -from discord.ext.commands import NoPrivateMessage - -from bot.cogs.filters import security -from tests.helpers import MockBot, MockContext - - -class SecurityCogTests(unittest.TestCase): - """Tests the `Security` cog.""" - - def setUp(self): - """Attach an instance of the cog to the class for tests.""" - self.bot = MockBot() - self.cog = security.Security(self.bot) - self.ctx = MockContext() - - def test_check_additions(self): - """The cog should add its checks after initialization.""" - self.bot.check.assert_any_call(self.cog.check_on_guild) - self.bot.check.assert_any_call(self.cog.check_not_bot) - - def test_check_not_bot_returns_false_for_humans(self): - """The bot check should return `True` when invoked with human authors.""" - self.ctx.author.bot = False - self.assertTrue(self.cog.check_not_bot(self.ctx)) - - def test_check_not_bot_returns_true_for_robots(self): - """The bot check should return `False` when invoked with robotic authors.""" - self.ctx.author.bot = True - self.assertFalse(self.cog.check_not_bot(self.ctx)) - - def test_check_on_guild_raises_when_outside_of_guild(self): - """When invoked outside of a guild, `check_on_guild` should cause an error.""" - self.ctx.guild = None - - with self.assertRaises(NoPrivateMessage, msg="This command cannot be used in private messages."): - self.cog.check_on_guild(self.ctx) - - def test_check_on_guild_returns_true_inside_of_guild(self): - """When invoked inside of a guild, `check_on_guild` should return `True`.""" - self.ctx.guild = "lemon's lemonade stand" - self.assertTrue(self.cog.check_on_guild(self.ctx)) - - -class SecurityCogLoadTests(unittest.TestCase): - """Tests loading the `Security` cog.""" - - def test_security_cog_load(self): - """Setup of the extension should call add_cog.""" - bot = MagicMock() - security.setup(bot) - bot.add_cog.assert_called_once() diff --git a/tests/bot/cogs/test_slowmode.py b/tests/bot/cogs/test_slowmode.py deleted file mode 100644 index f442814c8..000000000 --- a/tests/bot/cogs/test_slowmode.py +++ /dev/null @@ -1,111 +0,0 @@ -import unittest -from unittest import mock - -from dateutil.relativedelta import relativedelta - -from bot.cogs.moderation.slowmode import Slowmode -from bot.constants import Emojis -from tests.helpers import MockBot, MockContext, MockTextChannel - - -class SlowmodeTests(unittest.IsolatedAsyncioTestCase): - - def setUp(self) -> None: - self.bot = MockBot() - self.cog = Slowmode(self.bot) - self.ctx = MockContext() - - async def test_get_slowmode_no_channel(self) -> None: - """Get slowmode without a given channel.""" - self.ctx.channel = MockTextChannel(name='python-general', slowmode_delay=5) - - await self.cog.get_slowmode(self.cog, self.ctx, None) - self.ctx.send.assert_called_once_with("The slowmode delay for #python-general is 5 seconds.") - - async def test_get_slowmode_with_channel(self) -> None: - """Get slowmode with a given channel.""" - text_channel = MockTextChannel(name='python-language', slowmode_delay=2) - - await self.cog.get_slowmode(self.cog, self.ctx, text_channel) - self.ctx.send.assert_called_once_with('The slowmode delay for #python-language is 2 seconds.') - - async def test_set_slowmode_no_channel(self) -> None: - """Set slowmode without a given channel.""" - test_cases = ( - ('helpers', 23, True, f'{Emojis.check_mark} The slowmode delay for #helpers is now 23 seconds.'), - ('mods', 76526, False, f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.'), - ('admins', 97, True, f'{Emojis.check_mark} The slowmode delay for #admins is now 1 minute and 37 seconds.') - ) - - for channel_name, seconds, edited, result_msg in test_cases: - with self.subTest( - channel_mention=channel_name, - seconds=seconds, - edited=edited, - result_msg=result_msg - ): - self.ctx.channel = MockTextChannel(name=channel_name) - - await self.cog.set_slowmode(self.cog, self.ctx, None, relativedelta(seconds=seconds)) - - if edited: - self.ctx.channel.edit.assert_awaited_once_with(slowmode_delay=float(seconds)) - else: - self.ctx.channel.edit.assert_not_called() - - self.ctx.send.assert_called_once_with(result_msg) - - self.ctx.reset_mock() - - async def test_set_slowmode_with_channel(self) -> None: - """Set slowmode with a given channel.""" - test_cases = ( - ('bot-commands', 12, True, f'{Emojis.check_mark} The slowmode delay for #bot-commands is now 12 seconds.'), - ('mod-spam', 21, True, f'{Emojis.check_mark} The slowmode delay for #mod-spam is now 21 seconds.'), - ('admin-spam', 4323598, False, f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.') - ) - - for channel_name, seconds, edited, result_msg in test_cases: - with self.subTest( - channel_mention=channel_name, - seconds=seconds, - edited=edited, - result_msg=result_msg - ): - text_channel = MockTextChannel(name=channel_name) - - await self.cog.set_slowmode(self.cog, self.ctx, text_channel, relativedelta(seconds=seconds)) - - if edited: - text_channel.edit.assert_awaited_once_with(slowmode_delay=float(seconds)) - else: - text_channel.edit.assert_not_called() - - self.ctx.send.assert_called_once_with(result_msg) - - self.ctx.reset_mock() - - async def test_reset_slowmode_no_channel(self) -> None: - """Reset slowmode without a given channel.""" - self.ctx.channel = MockTextChannel(name='careers', slowmode_delay=6) - - await self.cog.reset_slowmode(self.cog, self.ctx, None) - self.ctx.send.assert_called_once_with( - f'{Emojis.check_mark} The slowmode delay for #careers has been reset to 0 seconds.' - ) - - async def test_reset_slowmode_with_channel(self) -> None: - """Reset slowmode with a given channel.""" - text_channel = MockTextChannel(name='meta', slowmode_delay=1) - - await self.cog.reset_slowmode(self.cog, self.ctx, text_channel) - self.ctx.send.assert_called_once_with( - f'{Emojis.check_mark} The slowmode delay for #meta has been reset to 0 seconds.' - ) - - @mock.patch("bot.cogs.moderation.slowmode.with_role_check") - @mock.patch("bot.cogs.moderation.slowmode.MODERATION_ROLES", new=(1, 2, 3)) - def test_cog_check(self, role_check): - """Role check is called with `MODERATION_ROLES`""" - self.cog.cog_check(self.ctx) - role_check.assert_called_once_with(self.ctx, *(1, 2, 3)) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py deleted file mode 100644 index c7bac3ab3..000000000 --- a/tests/bot/cogs/test_snekbox.py +++ /dev/null @@ -1,409 +0,0 @@ -import asyncio -import logging -import unittest -from unittest.mock import AsyncMock, MagicMock, Mock, call, create_autospec, patch - -from discord.ext import commands - -from bot import constants -from bot.cogs.utils import snekbox -from bot.cogs.utils.snekbox import Snekbox -from tests.helpers import MockBot, MockContext, MockMessage, MockReaction, MockUser - - -class SnekboxTests(unittest.IsolatedAsyncioTestCase): - def setUp(self): - """Add mocked bot and cog to the instance.""" - self.bot = MockBot() - self.cog = Snekbox(bot=self.bot) - - async def test_post_eval(self): - """Post the eval code to the URLs.snekbox_eval_api endpoint.""" - resp = MagicMock() - resp.json = AsyncMock(return_value="return") - - context_manager = MagicMock() - context_manager.__aenter__.return_value = resp - self.bot.http_session.post.return_value = context_manager - - self.assertEqual(await self.cog.post_eval("import random"), "return") - self.bot.http_session.post.assert_called_with( - constants.URLs.snekbox_eval_api, - json={"input": "import random"}, - raise_for_status=True - ) - resp.json.assert_awaited_once() - - async def test_upload_output_reject_too_long(self): - """Reject output longer than MAX_PASTE_LEN.""" - result = await self.cog.upload_output("-" * (snekbox.MAX_PASTE_LEN + 1)) - self.assertEqual(result, "too long to upload") - - async def test_upload_output(self): - """Upload the eval output to the URLs.paste_service.format(key="documents") endpoint.""" - key = "MarkDiamond" - resp = MagicMock() - resp.json = AsyncMock(return_value={"key": key}) - - context_manager = MagicMock() - context_manager.__aenter__.return_value = resp - self.bot.http_session.post.return_value = context_manager - - self.assertEqual( - await self.cog.upload_output("My awesome output"), - constants.URLs.paste_service.format(key=key) - ) - self.bot.http_session.post.assert_called_with( - constants.URLs.paste_service.format(key="documents"), - data="My awesome output", - raise_for_status=True - ) - - async def test_upload_output_gracefully_fallback_if_exception_during_request(self): - """Output upload gracefully fallback if the upload fail.""" - resp = MagicMock() - resp.json = AsyncMock(side_effect=Exception) - - context_manager = MagicMock() - context_manager.__aenter__.return_value = resp - self.bot.http_session.post.return_value = context_manager - - log = logging.getLogger("bot.cogs.snekbox") - with self.assertLogs(logger=log, level='ERROR'): - await self.cog.upload_output('My awesome output!') - - async def test_upload_output_gracefully_fallback_if_no_key_in_response(self): - """Output upload gracefully fallback if there is no key entry in the response body.""" - self.assertEqual((await self.cog.upload_output('My awesome output!')), None) - - def test_prepare_input(self): - cases = ( - ('print("Hello world!")', 'print("Hello world!")', 'non-formatted'), - ('`print("Hello world!")`', 'print("Hello world!")', 'one line code block'), - ('```\nprint("Hello world!")```', 'print("Hello world!")', 'multiline code block'), - ('```py\nprint("Hello world!")```', 'print("Hello world!")', 'multiline python code block'), - ) - for case, expected, testname in cases: - with self.subTest(msg=f'Extract code from {testname}.'): - self.assertEqual(self.cog.prepare_input(case), expected) - - def test_get_results_message(self): - """Return error and message according to the eval result.""" - cases = ( - ('ERROR', None, ('Your eval job has failed', 'ERROR')), - ('', 128 + snekbox.SIGKILL, ('Your eval job timed out or ran out of memory', '')), - ('', 255, ('Your eval job has failed', 'A fatal NsJail error occurred')) - ) - for stdout, returncode, expected in cases: - with self.subTest(stdout=stdout, returncode=returncode, expected=expected): - actual = self.cog.get_results_message({'stdout': stdout, 'returncode': returncode}) - self.assertEqual(actual, expected) - - @patch('bot.cogs.snekbox.Signals', side_effect=ValueError) - def test_get_results_message_invalid_signal(self, mock_signals: Mock): - self.assertEqual( - self.cog.get_results_message({'stdout': '', 'returncode': 127}), - ('Your eval job has completed with return code 127', '') - ) - - @patch('bot.cogs.snekbox.Signals') - def test_get_results_message_valid_signal(self, mock_signals: Mock): - mock_signals.return_value.name = 'SIGTEST' - self.assertEqual( - self.cog.get_results_message({'stdout': '', 'returncode': 127}), - ('Your eval job has completed with return code 127 (SIGTEST)', '') - ) - - def test_get_status_emoji(self): - """Return emoji according to the eval result.""" - cases = ( - (' ', -1, ':warning:'), - ('Hello world!', 0, ':white_check_mark:'), - ('Invalid beard size', -1, ':x:') - ) - for stdout, returncode, expected in cases: - with self.subTest(stdout=stdout, returncode=returncode, expected=expected): - actual = self.cog.get_status_emoji({'stdout': stdout, 'returncode': returncode}) - self.assertEqual(actual, expected) - - async def test_format_output(self): - """Test output formatting.""" - self.cog.upload_output = AsyncMock(return_value='https://testificate.com/') - - too_many_lines = ( - '001 | v\n002 | e\n003 | r\n004 | y\n005 | l\n006 | o\n' - '007 | n\n008 | g\n009 | b\n010 | e\n011 | a\n... (truncated - too many lines)' - ) - too_long_too_many_lines = ( - "\n".join( - f"{i:03d} | {line}" for i, line in enumerate(['verylongbeard' * 10] * 15, 1) - )[:1000] + "\n... (truncated - too long, too many lines)" - ) - - cases = ( - ('', ('[No output]', None), 'No output'), - ('My awesome output', ('My awesome output', None), 'One line output'), - ('<@', ("<@\u200B", None), r'Convert <@ to <@\u200B'), - (' CategoryChannel: + """Return a mocked code jam category.""" + category = create_autospec(CategoryChannel, spec_set=True, instance=True) + category.name = name + category.channels = [MockTextChannel() for _ in range(channel_count)] + + return category + + +class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): + """Tests for `createteam` command.""" + + def setUp(self): + self.bot = MockBot() + self.admin_role = MockRole(name="Admins", id=Roles.admins) + self.command_user = MockMember([self.admin_role]) + self.guild = MockGuild([self.admin_role]) + self.ctx = MockContext(bot=self.bot, author=self.command_user, guild=self.guild) + self.cog = jams.CodeJams(self.bot) + + async def test_too_small_amount_of_team_members_passed(self): + """Should `ctx.send` and exit early when too small amount of members.""" + for case in (1, 2): + with self.subTest(amount_of_members=case): + self.cog.create_channels = AsyncMock() + self.cog.add_roles = AsyncMock() + + self.ctx.reset_mock() + members = (MockMember() for _ in range(case)) + await self.cog.createteam(self.cog, self.ctx, "foo", members) + + self.ctx.send.assert_awaited_once() + self.cog.create_channels.assert_not_awaited() + self.cog.add_roles.assert_not_awaited() + + async def test_duplicate_members_provided(self): + """Should `ctx.send` and exit early because duplicate members provided and total there is only 1 member.""" + self.cog.create_channels = AsyncMock() + self.cog.add_roles = AsyncMock() + + member = MockMember() + await self.cog.createteam(self.cog, self.ctx, "foo", (member for _ in range(5))) + + self.ctx.send.assert_awaited_once() + self.cog.create_channels.assert_not_awaited() + self.cog.add_roles.assert_not_awaited() + + async def test_result_sending(self): + """Should call `ctx.send` when everything goes right.""" + self.cog.create_channels = AsyncMock() + self.cog.add_roles = AsyncMock() + + members = [MockMember() for _ in range(5)] + await self.cog.createteam(self.cog, self.ctx, "foo", members) + + self.cog.create_channels.assert_awaited_once() + self.cog.add_roles.assert_awaited_once() + self.ctx.send.assert_awaited_once() + + async def test_category_doesnt_exist(self): + """Should create a new code jam category.""" + subtests = ( + [], + [get_mock_category(jams.MAX_CHANNELS - 1, jams.CATEGORY_NAME)], + [get_mock_category(jams.MAX_CHANNELS - 2, "other")], + ) + + for categories in subtests: + self.guild.reset_mock() + self.guild.categories = categories + + with self.subTest(categories=categories): + actual_category = await self.cog.get_category(self.guild) + + self.guild.create_category_channel.assert_awaited_once() + category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"] + + self.assertFalse(category_overwrites[self.guild.default_role].read_messages) + self.assertTrue(category_overwrites[self.guild.me].read_messages) + self.assertEqual(self.guild.create_category_channel.return_value, actual_category) + + async def test_category_channel_exist(self): + """Should not try to create category channel.""" + expected_category = get_mock_category(jams.MAX_CHANNELS - 2, jams.CATEGORY_NAME) + self.guild.categories = [ + get_mock_category(jams.MAX_CHANNELS - 2, "other"), + expected_category, + get_mock_category(0, jams.CATEGORY_NAME), + ] + + actual_category = await self.cog.get_category(self.guild) + self.assertEqual(expected_category, actual_category) + + async def test_channel_overwrites(self): + """Should have correct permission overwrites for users and roles.""" + leader = MockMember() + members = [leader] + [MockMember() for _ in range(4)] + overwrites = self.cog.get_overwrites(members, self.guild) + + # Leader permission overwrites + self.assertTrue(overwrites[leader].manage_messages) + self.assertTrue(overwrites[leader].read_messages) + self.assertTrue(overwrites[leader].manage_webhooks) + self.assertTrue(overwrites[leader].connect) + + # Other members permission overwrites + for member in members[1:]: + self.assertTrue(overwrites[member].read_messages) + self.assertTrue(overwrites[member].connect) + + # Everyone and verified role overwrite + self.assertFalse(overwrites[self.guild.default_role].read_messages) + self.assertFalse(overwrites[self.guild.default_role].connect) + self.assertFalse(overwrites[self.guild.get_role(Roles.verified)].read_messages) + self.assertFalse(overwrites[self.guild.get_role(Roles.verified)].connect) + + async def test_team_channels_creation(self): + """Should create new voice and text channel for team.""" + members = [MockMember() for _ in range(5)] + + self.cog.get_overwrites = MagicMock() + self.cog.get_category = AsyncMock() + self.ctx.guild.create_text_channel.return_value = MockTextChannel(mention="foobar-channel") + actual = await self.cog.create_channels(self.guild, "my-team", members) + + self.assertEqual("foobar-channel", actual) + self.cog.get_overwrites.assert_called_once_with(members, self.guild) + self.cog.get_category.assert_awaited_once_with(self.guild) + + self.guild.create_text_channel.assert_awaited_once_with( + "my-team", + overwrites=self.cog.get_overwrites.return_value, + category=self.cog.get_category.return_value + ) + self.guild.create_voice_channel.assert_awaited_once_with( + "My Team", + overwrites=self.cog.get_overwrites.return_value, + category=self.cog.get_category.return_value + ) + + async def test_jam_roles_adding(self): + """Should add team leader role to leader and jam role to every team member.""" + leader_role = MockRole(name="Team Leader") + jam_role = MockRole(name="Jammer") + self.guild.get_role.side_effect = [leader_role, jam_role] + + leader = MockMember() + members = [leader] + [MockMember() for _ in range(4)] + await self.cog.add_roles(self.guild, members) + + leader.add_roles.assert_any_await(leader_role) + for member in members: + member.add_roles.assert_any_await(jam_role) + + +class CodeJamSetup(unittest.TestCase): + """Test for `setup` function of `CodeJam` cog.""" + + def test_setup(self): + """Should call `bot.add_cog`.""" + bot = MockBot() + jams.setup(bot) + bot.add_cog.assert_called_once() diff --git a/tests/bot/cogs/utils/test_snekbox.py b/tests/bot/cogs/utils/test_snekbox.py new file mode 100644 index 000000000..3e447f319 --- /dev/null +++ b/tests/bot/cogs/utils/test_snekbox.py @@ -0,0 +1,409 @@ +import asyncio +import logging +import unittest +from unittest.mock import AsyncMock, MagicMock, Mock, call, create_autospec, patch + +from discord.ext import commands + +from bot import constants +from bot.cogs.utils import snekbox +from bot.cogs.utils.snekbox import Snekbox +from tests.helpers import MockBot, MockContext, MockMessage, MockReaction, MockUser + + +class SnekboxTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + """Add mocked bot and cog to the instance.""" + self.bot = MockBot() + self.cog = Snekbox(bot=self.bot) + + async def test_post_eval(self): + """Post the eval code to the URLs.snekbox_eval_api endpoint.""" + resp = MagicMock() + resp.json = AsyncMock(return_value="return") + + context_manager = MagicMock() + context_manager.__aenter__.return_value = resp + self.bot.http_session.post.return_value = context_manager + + self.assertEqual(await self.cog.post_eval("import random"), "return") + self.bot.http_session.post.assert_called_with( + constants.URLs.snekbox_eval_api, + json={"input": "import random"}, + raise_for_status=True + ) + resp.json.assert_awaited_once() + + async def test_upload_output_reject_too_long(self): + """Reject output longer than MAX_PASTE_LEN.""" + result = await self.cog.upload_output("-" * (snekbox.MAX_PASTE_LEN + 1)) + self.assertEqual(result, "too long to upload") + + async def test_upload_output(self): + """Upload the eval output to the URLs.paste_service.format(key="documents") endpoint.""" + key = "MarkDiamond" + resp = MagicMock() + resp.json = AsyncMock(return_value={"key": key}) + + context_manager = MagicMock() + context_manager.__aenter__.return_value = resp + self.bot.http_session.post.return_value = context_manager + + self.assertEqual( + await self.cog.upload_output("My awesome output"), + constants.URLs.paste_service.format(key=key) + ) + self.bot.http_session.post.assert_called_with( + constants.URLs.paste_service.format(key="documents"), + data="My awesome output", + raise_for_status=True + ) + + async def test_upload_output_gracefully_fallback_if_exception_during_request(self): + """Output upload gracefully fallback if the upload fail.""" + resp = MagicMock() + resp.json = AsyncMock(side_effect=Exception) + + context_manager = MagicMock() + context_manager.__aenter__.return_value = resp + self.bot.http_session.post.return_value = context_manager + + log = logging.getLogger("bot.cogs.utils.snekbox") + with self.assertLogs(logger=log, level='ERROR'): + await self.cog.upload_output('My awesome output!') + + async def test_upload_output_gracefully_fallback_if_no_key_in_response(self): + """Output upload gracefully fallback if there is no key entry in the response body.""" + self.assertEqual((await self.cog.upload_output('My awesome output!')), None) + + def test_prepare_input(self): + cases = ( + ('print("Hello world!")', 'print("Hello world!")', 'non-formatted'), + ('`print("Hello world!")`', 'print("Hello world!")', 'one line code block'), + ('```\nprint("Hello world!")```', 'print("Hello world!")', 'multiline code block'), + ('```py\nprint("Hello world!")```', 'print("Hello world!")', 'multiline python code block'), + ) + for case, expected, testname in cases: + with self.subTest(msg=f'Extract code from {testname}.'): + self.assertEqual(self.cog.prepare_input(case), expected) + + def test_get_results_message(self): + """Return error and message according to the eval result.""" + cases = ( + ('ERROR', None, ('Your eval job has failed', 'ERROR')), + ('', 128 + snekbox.SIGKILL, ('Your eval job timed out or ran out of memory', '')), + ('', 255, ('Your eval job has failed', 'A fatal NsJail error occurred')) + ) + for stdout, returncode, expected in cases: + with self.subTest(stdout=stdout, returncode=returncode, expected=expected): + actual = self.cog.get_results_message({'stdout': stdout, 'returncode': returncode}) + self.assertEqual(actual, expected) + + @patch('bot.cogs.utils.snekbox.Signals', side_effect=ValueError) + def test_get_results_message_invalid_signal(self, mock_signals: Mock): + self.assertEqual( + self.cog.get_results_message({'stdout': '', 'returncode': 127}), + ('Your eval job has completed with return code 127', '') + ) + + @patch('bot.cogs.utils.snekbox.Signals') + def test_get_results_message_valid_signal(self, mock_signals: Mock): + mock_signals.return_value.name = 'SIGTEST' + self.assertEqual( + self.cog.get_results_message({'stdout': '', 'returncode': 127}), + ('Your eval job has completed with return code 127 (SIGTEST)', '') + ) + + def test_get_status_emoji(self): + """Return emoji according to the eval result.""" + cases = ( + (' ', -1, ':warning:'), + ('Hello world!', 0, ':white_check_mark:'), + ('Invalid beard size', -1, ':x:') + ) + for stdout, returncode, expected in cases: + with self.subTest(stdout=stdout, returncode=returncode, expected=expected): + actual = self.cog.get_status_emoji({'stdout': stdout, 'returncode': returncode}) + self.assertEqual(actual, expected) + + async def test_format_output(self): + """Test output formatting.""" + self.cog.upload_output = AsyncMock(return_value='https://testificate.com/') + + too_many_lines = ( + '001 | v\n002 | e\n003 | r\n004 | y\n005 | l\n006 | o\n' + '007 | n\n008 | g\n009 | b\n010 | e\n011 | a\n... (truncated - too many lines)' + ) + too_long_too_many_lines = ( + "\n".join( + f"{i:03d} | {line}" for i, line in enumerate(['verylongbeard' * 10] * 15, 1) + )[:1000] + "\n... (truncated - too long, too many lines)" + ) + + cases = ( + ('', ('[No output]', None), 'No output'), + ('My awesome output', ('My awesome output', None), 'One line output'), + ('<@', ("<@\u200B", None), r'Convert <@ to <@\u200B'), + (' Date: Wed, 12 Aug 2020 22:31:08 -0700 Subject: Prefix names of non-extension modules with _ This naming scheme will make them easy to distinguish from extensions. --- bot/cogs/backend/sync/__init__.py | 2 +- bot/cogs/backend/sync/_cog.py | 180 ++++++++ bot/cogs/backend/sync/_syncers.py | 347 +++++++++++++++ bot/cogs/backend/sync/cog.py | 180 -------- bot/cogs/backend/sync/syncers.py | 347 --------------- bot/cogs/moderation/__init__.py | 19 - bot/cogs/moderation/incidents.py | 5 + bot/cogs/moderation/infraction/_scheduler.py | 463 +++++++++++++++++++++ bot/cogs/moderation/infraction/_utils.py | 201 +++++++++ bot/cogs/moderation/infraction/infractions.py | 31 +- bot/cogs/moderation/infraction/management.py | 11 +- bot/cogs/moderation/infraction/scheduler.py | 463 --------------------- bot/cogs/moderation/infraction/superstarify.py | 29 +- bot/cogs/moderation/infraction/utils.py | 201 --------- bot/cogs/moderation/modlog.py | 5 + bot/cogs/moderation/silence.py | 5 + bot/cogs/moderation/watchchannels/__init__.py | 9 - bot/cogs/moderation/watchchannels/_watchchannel.py | 348 ++++++++++++++++ bot/cogs/moderation/watchchannels/bigbrother.py | 9 +- bot/cogs/moderation/watchchannels/talentpool.py | 7 +- bot/cogs/moderation/watchchannels/watchchannel.py | 348 ---------------- tests/bot/cogs/backend/sync/test_base.py | 2 +- tests/bot/cogs/backend/sync/test_cog.py | 15 +- tests/bot/cogs/backend/sync/test_roles.py | 2 +- tests/bot/cogs/backend/sync/test_users.py | 2 +- .../cogs/moderation/infraction/test_infractions.py | 6 +- 26 files changed, 1625 insertions(+), 1612 deletions(-) create mode 100644 bot/cogs/backend/sync/_cog.py create mode 100644 bot/cogs/backend/sync/_syncers.py delete mode 100644 bot/cogs/backend/sync/cog.py delete mode 100644 bot/cogs/backend/sync/syncers.py create mode 100644 bot/cogs/moderation/infraction/_scheduler.py create mode 100644 bot/cogs/moderation/infraction/_utils.py delete mode 100644 bot/cogs/moderation/infraction/scheduler.py delete mode 100644 bot/cogs/moderation/infraction/utils.py create mode 100644 bot/cogs/moderation/watchchannels/_watchchannel.py delete mode 100644 bot/cogs/moderation/watchchannels/watchchannel.py diff --git a/bot/cogs/backend/sync/__init__.py b/bot/cogs/backend/sync/__init__.py index fe7df4e9b..fb640a1cf 100644 --- a/bot/cogs/backend/sync/__init__.py +++ b/bot/cogs/backend/sync/__init__.py @@ -1,5 +1,5 @@ from bot.bot import Bot -from .cog import Sync +from ._cog import Sync def setup(bot: Bot) -> None: diff --git a/bot/cogs/backend/sync/_cog.py b/bot/cogs/backend/sync/_cog.py new file mode 100644 index 000000000..b6068f328 --- /dev/null +++ b/bot/cogs/backend/sync/_cog.py @@ -0,0 +1,180 @@ +import logging +from typing import Any, Dict + +from discord import Member, Role, User +from discord.ext import commands +from discord.ext.commands import Cog, Context + +from bot import constants +from bot.api import ResponseCodeError +from bot.bot import Bot +from . import _syncers + +log = logging.getLogger(__name__) + + +class Sync(Cog): + """Captures relevant events and sends them to the site.""" + + def __init__(self, bot: Bot) -> None: + self.bot = bot + self.role_syncer = _syncers.RoleSyncer(self.bot) + self.user_syncer = _syncers.UserSyncer(self.bot) + + self.bot.loop.create_task(self.sync_guild()) + + async def sync_guild(self) -> None: + """Syncs the roles/users of the guild with the database.""" + await self.bot.wait_until_guild_available() + + guild = self.bot.get_guild(constants.Guild.id) + if guild is None: + return + + for syncer in (self.role_syncer, self.user_syncer): + await syncer.sync(guild) + + async def patch_user(self, user_id: int, json: Dict[str, Any], ignore_404: bool = False) -> None: + """Send a PATCH request to partially update a user in the database.""" + try: + await self.bot.api_client.patch(f"bot/users/{user_id}", json=json) + except ResponseCodeError as e: + if e.response.status != 404: + raise + if not ignore_404: + log.warning("Unable to update user, got 404. Assuming race condition from join event.") + + @Cog.listener() + async def on_guild_role_create(self, role: Role) -> None: + """Adds newly create role to the database table over the API.""" + if role.guild.id != constants.Guild.id: + return + + await self.bot.api_client.post( + 'bot/roles', + json={ + 'colour': role.colour.value, + 'id': role.id, + 'name': role.name, + 'permissions': role.permissions.value, + 'position': role.position, + } + ) + + @Cog.listener() + async def on_guild_role_delete(self, role: Role) -> None: + """Deletes role from the database when it's deleted from the guild.""" + if role.guild.id != constants.Guild.id: + return + + await self.bot.api_client.delete(f'bot/roles/{role.id}') + + @Cog.listener() + async def on_guild_role_update(self, before: Role, after: Role) -> None: + """Syncs role with the database if any of the stored attributes were updated.""" + if after.guild.id != constants.Guild.id: + return + + was_updated = ( + before.name != after.name + or before.colour != after.colour + or before.permissions != after.permissions + or before.position != after.position + ) + + if was_updated: + await self.bot.api_client.put( + f'bot/roles/{after.id}', + json={ + 'colour': after.colour.value, + 'id': after.id, + 'name': after.name, + 'permissions': after.permissions.value, + 'position': after.position, + } + ) + + @Cog.listener() + async def on_member_join(self, member: Member) -> None: + """ + Adds a new user or updates existing user to the database when a member joins the guild. + + If the joining member is a user that is already known to the database (i.e., a user that + previously left), it will update the user's information. If the user is not yet known by + the database, the user is added. + """ + if member.guild.id != constants.Guild.id: + return + + packed = { + 'discriminator': int(member.discriminator), + 'id': member.id, + 'in_guild': True, + 'name': member.name, + 'roles': sorted(role.id for role in member.roles) + } + + got_error = False + + try: + # First try an update of the user to set the `in_guild` field and other + # fields that may have changed since the last time we've seen them. + await self.bot.api_client.put(f'bot/users/{member.id}', json=packed) + + except ResponseCodeError as e: + # If we didn't get 404, something else broke - propagate it up. + if e.response.status != 404: + raise + + got_error = True # yikes + + if got_error: + # If we got `404`, the user is new. Create them. + await self.bot.api_client.post('bot/users', json=packed) + + @Cog.listener() + async def on_member_remove(self, member: Member) -> None: + """Set the in_guild field to False when a member leaves the guild.""" + if member.guild.id != constants.Guild.id: + return + + await self.patch_user(member.id, json={"in_guild": False}) + + @Cog.listener() + async def on_member_update(self, before: Member, after: Member) -> None: + """Update the roles of the member in the database if a change is detected.""" + if after.guild.id != constants.Guild.id: + return + + if before.roles != after.roles: + updated_information = {"roles": sorted(role.id for role in after.roles)} + await self.patch_user(after.id, json=updated_information) + + @Cog.listener() + async def on_user_update(self, before: User, after: User) -> None: + """Update the user information in the database if a relevant change is detected.""" + attrs = ("name", "discriminator") + if any(getattr(before, attr) != getattr(after, attr) for attr in attrs): + updated_information = { + "name": after.name, + "discriminator": int(after.discriminator), + } + # A 404 likely means the user is in another guild. + await self.patch_user(after.id, json=updated_information, ignore_404=True) + + @commands.group(name='sync') + @commands.has_permissions(administrator=True) + async def sync_group(self, ctx: Context) -> None: + """Run synchronizations between the bot and site manually.""" + + @sync_group.command(name='roles') + @commands.has_permissions(administrator=True) + async def sync_roles_command(self, ctx: Context) -> None: + """Manually synchronise the guild's roles with the roles on the site.""" + await self.role_syncer.sync(ctx.guild, ctx) + + @sync_group.command(name='users') + @commands.has_permissions(administrator=True) + async def sync_users_command(self, ctx: Context) -> None: + """Manually synchronise the guild's users with the users on the site.""" + await self.user_syncer.sync(ctx.guild, ctx) diff --git a/bot/cogs/backend/sync/_syncers.py b/bot/cogs/backend/sync/_syncers.py new file mode 100644 index 000000000..f7ba811bc --- /dev/null +++ b/bot/cogs/backend/sync/_syncers.py @@ -0,0 +1,347 @@ +import abc +import asyncio +import logging +import typing as t +from collections import namedtuple +from functools import partial + +import discord +from discord import Guild, HTTPException, Member, Message, Reaction, User +from discord.ext.commands import Context + +from bot import constants +from bot.api import ResponseCodeError +from bot.bot import Bot + +log = logging.getLogger(__name__) + +# These objects are declared as namedtuples because tuples are hashable, +# something that we make use of when diffing site roles against guild roles. +_Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions', 'position')) +_User = namedtuple('User', ('id', 'name', 'discriminator', 'roles', 'in_guild')) +_Diff = namedtuple('Diff', ('created', 'updated', 'deleted')) + + +class Syncer(abc.ABC): + """Base class for synchronising the database with objects in the Discord cache.""" + + _CORE_DEV_MENTION = f"<@&{constants.Roles.core_developers}> " + _REACTION_EMOJIS = (constants.Emojis.check_mark, constants.Emojis.cross_mark) + + def __init__(self, bot: Bot) -> None: + self.bot = bot + + @property + @abc.abstractmethod + def name(self) -> str: + """The name of the syncer; used in output messages and logging.""" + raise NotImplementedError # pragma: no cover + + async def _send_prompt(self, message: t.Optional[Message] = None) -> t.Optional[Message]: + """ + Send a prompt to confirm or abort a sync using reactions and return the sent message. + + If a message is given, it is edited to display the prompt and reactions. Otherwise, a new + message is sent to the dev-core channel and mentions the core developers role. If the + channel cannot be retrieved, return None. + """ + log.trace(f"Sending {self.name} sync confirmation prompt.") + + msg_content = ( + f'Possible cache issue while syncing {self.name}s. ' + f'More than {constants.Sync.max_diff} {self.name}s were changed. ' + f'React to confirm or abort the sync.' + ) + + # Send to core developers if it's an automatic sync. + if not message: + log.trace("Message not provided for confirmation; creating a new one in dev-core.") + channel = self.bot.get_channel(constants.Channels.dev_core) + + if not channel: + log.debug("Failed to get the dev-core channel from cache; attempting to fetch it.") + try: + channel = await self.bot.fetch_channel(constants.Channels.dev_core) + except HTTPException: + log.exception( + f"Failed to fetch channel for sending sync confirmation prompt; " + f"aborting {self.name} sync." + ) + return None + + allowed_roles = [discord.Object(constants.Roles.core_developers)] + message = await channel.send( + f"{self._CORE_DEV_MENTION}{msg_content}", + allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) + ) + else: + await message.edit(content=msg_content) + + # Add the initial reactions. + log.trace(f"Adding reactions to {self.name} syncer confirmation prompt.") + for emoji in self._REACTION_EMOJIS: + await message.add_reaction(emoji) + + return message + + def _reaction_check( + self, + author: Member, + message: Message, + reaction: Reaction, + user: t.Union[Member, User] + ) -> bool: + """ + Return True if the `reaction` is a valid confirmation or abort reaction on `message`. + + If the `author` of the prompt is a bot, then a reaction by any core developer will be + considered valid. Otherwise, the author of the reaction (`user`) will have to be the + `author` of the prompt. + """ + # For automatic syncs, check for the core dev role instead of an exact author + has_role = any(constants.Roles.core_developers == role.id for role in user.roles) + return ( + reaction.message.id == message.id + and not user.bot + and (has_role if author.bot else user == author) + and str(reaction.emoji) in self._REACTION_EMOJIS + ) + + async def _wait_for_confirmation(self, author: Member, message: Message) -> bool: + """ + Wait for a confirmation reaction by `author` on `message` and return True if confirmed. + + Uses the `_reaction_check` function to determine if a reaction is valid. + + If there is no reaction within `bot.constants.Sync.confirm_timeout` seconds, return False. + To acknowledge the reaction (or lack thereof), `message` will be edited. + """ + # Preserve the core-dev role mention in the message edits so users aren't confused about + # where notifications came from. + mention = self._CORE_DEV_MENTION if author.bot else "" + + reaction = None + try: + log.trace(f"Waiting for a reaction to the {self.name} syncer confirmation prompt.") + reaction, _ = await self.bot.wait_for( + 'reaction_add', + check=partial(self._reaction_check, author, message), + timeout=constants.Sync.confirm_timeout + ) + except asyncio.TimeoutError: + # reaction will remain none thus sync will be aborted in the finally block below. + log.debug(f"The {self.name} syncer confirmation prompt timed out.") + + if str(reaction) == constants.Emojis.check_mark: + log.trace(f"The {self.name} syncer was confirmed.") + await message.edit(content=f':ok_hand: {mention}{self.name} sync will proceed.') + return True + else: + log.info(f"The {self.name} syncer was aborted or timed out!") + await message.edit( + content=f':warning: {mention}{self.name} sync aborted or timed out!' + ) + return False + + @abc.abstractmethod + async def _get_diff(self, guild: Guild) -> _Diff: + """Return the difference between the cache of `guild` and the database.""" + raise NotImplementedError # pragma: no cover + + @abc.abstractmethod + async def _sync(self, diff: _Diff) -> None: + """Perform the API calls for synchronisation.""" + raise NotImplementedError # pragma: no cover + + async def _get_confirmation_result( + self, + diff_size: int, + author: Member, + message: t.Optional[Message] = None + ) -> t.Tuple[bool, t.Optional[Message]]: + """ + Prompt for confirmation and return a tuple of the result and the prompt message. + + `diff_size` is the size of the diff of the sync. If it is greater than + `bot.constants.Sync.max_diff`, the prompt will be sent. The `author` is the invoked of the + sync and the `message` is an extant message to edit to display the prompt. + + If confirmed or no confirmation was needed, the result is True. The returned message will + either be the given `message` or a new one which was created when sending the prompt. + """ + log.trace(f"Determining if confirmation prompt should be sent for {self.name} syncer.") + if diff_size > constants.Sync.max_diff: + message = await self._send_prompt(message) + if not message: + return False, None # Couldn't get channel. + + confirmed = await self._wait_for_confirmation(author, message) + if not confirmed: + return False, message # Sync aborted. + + return True, message + + async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> None: + """ + Synchronise the database with the cache of `guild`. + + If the differences between the cache and the database are greater than + `bot.constants.Sync.max_diff`, then a confirmation prompt will be sent to the dev-core + channel. The confirmation can be optionally redirect to `ctx` instead. + """ + log.info(f"Starting {self.name} syncer.") + + message = None + author = self.bot.user + if ctx: + message = await ctx.send(f"📊 Synchronising {self.name}s.") + author = ctx.author + + diff = await self._get_diff(guild) + diff_dict = diff._asdict() # Ugly method for transforming the NamedTuple into a dict + totals = {k: len(v) for k, v in diff_dict.items() if v is not None} + diff_size = sum(totals.values()) + + confirmed, message = await self._get_confirmation_result(diff_size, author, message) + if not confirmed: + return + + # Preserve the core-dev role mention in the message edits so users aren't confused about + # where notifications came from. + mention = self._CORE_DEV_MENTION if author.bot else "" + + try: + await self._sync(diff) + except ResponseCodeError as e: + log.exception(f"{self.name} syncer failed!") + + # Don't show response text because it's probably some really long HTML. + results = f"status {e.status}\n```{e.response_json or 'See log output for details'}```" + content = f":x: {mention}Synchronisation of {self.name}s failed: {results}" + else: + results = ", ".join(f"{name} `{total}`" for name, total in totals.items()) + log.info(f"{self.name} syncer finished: {results}.") + content = f":ok_hand: {mention}Synchronisation of {self.name}s complete: {results}" + + if message: + await message.edit(content=content) + + +class RoleSyncer(Syncer): + """Synchronise the database with roles in the cache.""" + + name = "role" + + async def _get_diff(self, guild: Guild) -> _Diff: + """Return the difference of roles between the cache of `guild` and the database.""" + log.trace("Getting the diff for roles.") + roles = await self.bot.api_client.get('bot/roles') + + # Pack DB roles and guild roles into one common, hashable format. + # They're hashable so that they're easily comparable with sets later. + db_roles = {_Role(**role_dict) for role_dict in roles} + guild_roles = { + _Role( + id=role.id, + name=role.name, + colour=role.colour.value, + permissions=role.permissions.value, + position=role.position, + ) + for role in guild.roles + } + + guild_role_ids = {role.id for role in guild_roles} + api_role_ids = {role.id for role in db_roles} + new_role_ids = guild_role_ids - api_role_ids + deleted_role_ids = api_role_ids - guild_role_ids + + # New roles are those which are on the cached guild but not on the + # DB guild, going by the role ID. We need to send them in for creation. + roles_to_create = {role for role in guild_roles if role.id in new_role_ids} + roles_to_update = guild_roles - db_roles - roles_to_create + roles_to_delete = {role for role in db_roles if role.id in deleted_role_ids} + + return _Diff(roles_to_create, roles_to_update, roles_to_delete) + + async def _sync(self, diff: _Diff) -> None: + """Synchronise the database with the role cache of `guild`.""" + log.trace("Syncing created roles...") + for role in diff.created: + await self.bot.api_client.post('bot/roles', json=role._asdict()) + + log.trace("Syncing updated roles...") + for role in diff.updated: + await self.bot.api_client.put(f'bot/roles/{role.id}', json=role._asdict()) + + log.trace("Syncing deleted roles...") + for role in diff.deleted: + await self.bot.api_client.delete(f'bot/roles/{role.id}') + + +class UserSyncer(Syncer): + """Synchronise the database with users in the cache.""" + + name = "user" + + async def _get_diff(self, guild: Guild) -> _Diff: + """Return the difference of users between the cache of `guild` and the database.""" + log.trace("Getting the diff for users.") + users = await self.bot.api_client.get('bot/users') + + # Pack DB roles and guild roles into one common, hashable format. + # They're hashable so that they're easily comparable with sets later. + db_users = { + user_dict['id']: _User( + roles=tuple(sorted(user_dict.pop('roles'))), + **user_dict + ) + for user_dict in users + } + guild_users = { + member.id: _User( + id=member.id, + name=member.name, + discriminator=int(member.discriminator), + roles=tuple(sorted(role.id for role in member.roles)), + in_guild=True + ) + for member in guild.members + } + + users_to_create = set() + users_to_update = set() + + for db_user in db_users.values(): + guild_user = guild_users.get(db_user.id) + if guild_user is not None: + if db_user != guild_user: + users_to_update.add(guild_user) + + elif db_user.in_guild: + # The user is known in the DB but not the guild, and the + # DB currently specifies that the user is a member of the guild. + # This means that the user has left since the last sync. + # Update the `in_guild` attribute of the user on the site + # to signify that the user left. + new_api_user = db_user._replace(in_guild=False) + users_to_update.add(new_api_user) + + new_user_ids = set(guild_users.keys()) - set(db_users.keys()) + for user_id in new_user_ids: + # The user is known on the guild but not on the API. This means + # that the user has joined since the last sync. Create it. + new_user = guild_users[user_id] + users_to_create.add(new_user) + + return _Diff(users_to_create, users_to_update, None) + + async def _sync(self, diff: _Diff) -> None: + """Synchronise the database with the user cache of `guild`.""" + log.trace("Syncing created users...") + for user in diff.created: + await self.bot.api_client.post('bot/users', json=user._asdict()) + + log.trace("Syncing updated users...") + for user in diff.updated: + await self.bot.api_client.put(f'bot/users/{user.id}', json=user._asdict()) diff --git a/bot/cogs/backend/sync/cog.py b/bot/cogs/backend/sync/cog.py deleted file mode 100644 index 274845a50..000000000 --- a/bot/cogs/backend/sync/cog.py +++ /dev/null @@ -1,180 +0,0 @@ -import logging -from typing import Any, Dict - -from discord import Member, Role, User -from discord.ext import commands -from discord.ext.commands import Cog, Context - -from bot import constants -from bot.api import ResponseCodeError -from bot.bot import Bot -from . import syncers - -log = logging.getLogger(__name__) - - -class Sync(Cog): - """Captures relevant events and sends them to the site.""" - - def __init__(self, bot: Bot) -> None: - self.bot = bot - self.role_syncer = syncers.RoleSyncer(self.bot) - self.user_syncer = syncers.UserSyncer(self.bot) - - self.bot.loop.create_task(self.sync_guild()) - - async def sync_guild(self) -> None: - """Syncs the roles/users of the guild with the database.""" - await self.bot.wait_until_guild_available() - - guild = self.bot.get_guild(constants.Guild.id) - if guild is None: - return - - for syncer in (self.role_syncer, self.user_syncer): - await syncer.sync(guild) - - async def patch_user(self, user_id: int, json: Dict[str, Any], ignore_404: bool = False) -> None: - """Send a PATCH request to partially update a user in the database.""" - try: - await self.bot.api_client.patch(f"bot/users/{user_id}", json=json) - except ResponseCodeError as e: - if e.response.status != 404: - raise - if not ignore_404: - log.warning("Unable to update user, got 404. Assuming race condition from join event.") - - @Cog.listener() - async def on_guild_role_create(self, role: Role) -> None: - """Adds newly create role to the database table over the API.""" - if role.guild.id != constants.Guild.id: - return - - await self.bot.api_client.post( - 'bot/roles', - json={ - 'colour': role.colour.value, - 'id': role.id, - 'name': role.name, - 'permissions': role.permissions.value, - 'position': role.position, - } - ) - - @Cog.listener() - async def on_guild_role_delete(self, role: Role) -> None: - """Deletes role from the database when it's deleted from the guild.""" - if role.guild.id != constants.Guild.id: - return - - await self.bot.api_client.delete(f'bot/roles/{role.id}') - - @Cog.listener() - async def on_guild_role_update(self, before: Role, after: Role) -> None: - """Syncs role with the database if any of the stored attributes were updated.""" - if after.guild.id != constants.Guild.id: - return - - was_updated = ( - before.name != after.name - or before.colour != after.colour - or before.permissions != after.permissions - or before.position != after.position - ) - - if was_updated: - await self.bot.api_client.put( - f'bot/roles/{after.id}', - json={ - 'colour': after.colour.value, - 'id': after.id, - 'name': after.name, - 'permissions': after.permissions.value, - 'position': after.position, - } - ) - - @Cog.listener() - async def on_member_join(self, member: Member) -> None: - """ - Adds a new user or updates existing user to the database when a member joins the guild. - - If the joining member is a user that is already known to the database (i.e., a user that - previously left), it will update the user's information. If the user is not yet known by - the database, the user is added. - """ - if member.guild.id != constants.Guild.id: - return - - packed = { - 'discriminator': int(member.discriminator), - 'id': member.id, - 'in_guild': True, - 'name': member.name, - 'roles': sorted(role.id for role in member.roles) - } - - got_error = False - - try: - # First try an update of the user to set the `in_guild` field and other - # fields that may have changed since the last time we've seen them. - await self.bot.api_client.put(f'bot/users/{member.id}', json=packed) - - except ResponseCodeError as e: - # If we didn't get 404, something else broke - propagate it up. - if e.response.status != 404: - raise - - got_error = True # yikes - - if got_error: - # If we got `404`, the user is new. Create them. - await self.bot.api_client.post('bot/users', json=packed) - - @Cog.listener() - async def on_member_remove(self, member: Member) -> None: - """Set the in_guild field to False when a member leaves the guild.""" - if member.guild.id != constants.Guild.id: - return - - await self.patch_user(member.id, json={"in_guild": False}) - - @Cog.listener() - async def on_member_update(self, before: Member, after: Member) -> None: - """Update the roles of the member in the database if a change is detected.""" - if after.guild.id != constants.Guild.id: - return - - if before.roles != after.roles: - updated_information = {"roles": sorted(role.id for role in after.roles)} - await self.patch_user(after.id, json=updated_information) - - @Cog.listener() - async def on_user_update(self, before: User, after: User) -> None: - """Update the user information in the database if a relevant change is detected.""" - attrs = ("name", "discriminator") - if any(getattr(before, attr) != getattr(after, attr) for attr in attrs): - updated_information = { - "name": after.name, - "discriminator": int(after.discriminator), - } - # A 404 likely means the user is in another guild. - await self.patch_user(after.id, json=updated_information, ignore_404=True) - - @commands.group(name='sync') - @commands.has_permissions(administrator=True) - async def sync_group(self, ctx: Context) -> None: - """Run synchronizations between the bot and site manually.""" - - @sync_group.command(name='roles') - @commands.has_permissions(administrator=True) - async def sync_roles_command(self, ctx: Context) -> None: - """Manually synchronise the guild's roles with the roles on the site.""" - await self.role_syncer.sync(ctx.guild, ctx) - - @sync_group.command(name='users') - @commands.has_permissions(administrator=True) - async def sync_users_command(self, ctx: Context) -> None: - """Manually synchronise the guild's users with the users on the site.""" - await self.user_syncer.sync(ctx.guild, ctx) diff --git a/bot/cogs/backend/sync/syncers.py b/bot/cogs/backend/sync/syncers.py deleted file mode 100644 index f7ba811bc..000000000 --- a/bot/cogs/backend/sync/syncers.py +++ /dev/null @@ -1,347 +0,0 @@ -import abc -import asyncio -import logging -import typing as t -from collections import namedtuple -from functools import partial - -import discord -from discord import Guild, HTTPException, Member, Message, Reaction, User -from discord.ext.commands import Context - -from bot import constants -from bot.api import ResponseCodeError -from bot.bot import Bot - -log = logging.getLogger(__name__) - -# These objects are declared as namedtuples because tuples are hashable, -# something that we make use of when diffing site roles against guild roles. -_Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions', 'position')) -_User = namedtuple('User', ('id', 'name', 'discriminator', 'roles', 'in_guild')) -_Diff = namedtuple('Diff', ('created', 'updated', 'deleted')) - - -class Syncer(abc.ABC): - """Base class for synchronising the database with objects in the Discord cache.""" - - _CORE_DEV_MENTION = f"<@&{constants.Roles.core_developers}> " - _REACTION_EMOJIS = (constants.Emojis.check_mark, constants.Emojis.cross_mark) - - def __init__(self, bot: Bot) -> None: - self.bot = bot - - @property - @abc.abstractmethod - def name(self) -> str: - """The name of the syncer; used in output messages and logging.""" - raise NotImplementedError # pragma: no cover - - async def _send_prompt(self, message: t.Optional[Message] = None) -> t.Optional[Message]: - """ - Send a prompt to confirm or abort a sync using reactions and return the sent message. - - If a message is given, it is edited to display the prompt and reactions. Otherwise, a new - message is sent to the dev-core channel and mentions the core developers role. If the - channel cannot be retrieved, return None. - """ - log.trace(f"Sending {self.name} sync confirmation prompt.") - - msg_content = ( - f'Possible cache issue while syncing {self.name}s. ' - f'More than {constants.Sync.max_diff} {self.name}s were changed. ' - f'React to confirm or abort the sync.' - ) - - # Send to core developers if it's an automatic sync. - if not message: - log.trace("Message not provided for confirmation; creating a new one in dev-core.") - channel = self.bot.get_channel(constants.Channels.dev_core) - - if not channel: - log.debug("Failed to get the dev-core channel from cache; attempting to fetch it.") - try: - channel = await self.bot.fetch_channel(constants.Channels.dev_core) - except HTTPException: - log.exception( - f"Failed to fetch channel for sending sync confirmation prompt; " - f"aborting {self.name} sync." - ) - return None - - allowed_roles = [discord.Object(constants.Roles.core_developers)] - message = await channel.send( - f"{self._CORE_DEV_MENTION}{msg_content}", - allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) - ) - else: - await message.edit(content=msg_content) - - # Add the initial reactions. - log.trace(f"Adding reactions to {self.name} syncer confirmation prompt.") - for emoji in self._REACTION_EMOJIS: - await message.add_reaction(emoji) - - return message - - def _reaction_check( - self, - author: Member, - message: Message, - reaction: Reaction, - user: t.Union[Member, User] - ) -> bool: - """ - Return True if the `reaction` is a valid confirmation or abort reaction on `message`. - - If the `author` of the prompt is a bot, then a reaction by any core developer will be - considered valid. Otherwise, the author of the reaction (`user`) will have to be the - `author` of the prompt. - """ - # For automatic syncs, check for the core dev role instead of an exact author - has_role = any(constants.Roles.core_developers == role.id for role in user.roles) - return ( - reaction.message.id == message.id - and not user.bot - and (has_role if author.bot else user == author) - and str(reaction.emoji) in self._REACTION_EMOJIS - ) - - async def _wait_for_confirmation(self, author: Member, message: Message) -> bool: - """ - Wait for a confirmation reaction by `author` on `message` and return True if confirmed. - - Uses the `_reaction_check` function to determine if a reaction is valid. - - If there is no reaction within `bot.constants.Sync.confirm_timeout` seconds, return False. - To acknowledge the reaction (or lack thereof), `message` will be edited. - """ - # Preserve the core-dev role mention in the message edits so users aren't confused about - # where notifications came from. - mention = self._CORE_DEV_MENTION if author.bot else "" - - reaction = None - try: - log.trace(f"Waiting for a reaction to the {self.name} syncer confirmation prompt.") - reaction, _ = await self.bot.wait_for( - 'reaction_add', - check=partial(self._reaction_check, author, message), - timeout=constants.Sync.confirm_timeout - ) - except asyncio.TimeoutError: - # reaction will remain none thus sync will be aborted in the finally block below. - log.debug(f"The {self.name} syncer confirmation prompt timed out.") - - if str(reaction) == constants.Emojis.check_mark: - log.trace(f"The {self.name} syncer was confirmed.") - await message.edit(content=f':ok_hand: {mention}{self.name} sync will proceed.') - return True - else: - log.info(f"The {self.name} syncer was aborted or timed out!") - await message.edit( - content=f':warning: {mention}{self.name} sync aborted or timed out!' - ) - return False - - @abc.abstractmethod - async def _get_diff(self, guild: Guild) -> _Diff: - """Return the difference between the cache of `guild` and the database.""" - raise NotImplementedError # pragma: no cover - - @abc.abstractmethod - async def _sync(self, diff: _Diff) -> None: - """Perform the API calls for synchronisation.""" - raise NotImplementedError # pragma: no cover - - async def _get_confirmation_result( - self, - diff_size: int, - author: Member, - message: t.Optional[Message] = None - ) -> t.Tuple[bool, t.Optional[Message]]: - """ - Prompt for confirmation and return a tuple of the result and the prompt message. - - `diff_size` is the size of the diff of the sync. If it is greater than - `bot.constants.Sync.max_diff`, the prompt will be sent. The `author` is the invoked of the - sync and the `message` is an extant message to edit to display the prompt. - - If confirmed or no confirmation was needed, the result is True. The returned message will - either be the given `message` or a new one which was created when sending the prompt. - """ - log.trace(f"Determining if confirmation prompt should be sent for {self.name} syncer.") - if diff_size > constants.Sync.max_diff: - message = await self._send_prompt(message) - if not message: - return False, None # Couldn't get channel. - - confirmed = await self._wait_for_confirmation(author, message) - if not confirmed: - return False, message # Sync aborted. - - return True, message - - async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> None: - """ - Synchronise the database with the cache of `guild`. - - If the differences between the cache and the database are greater than - `bot.constants.Sync.max_diff`, then a confirmation prompt will be sent to the dev-core - channel. The confirmation can be optionally redirect to `ctx` instead. - """ - log.info(f"Starting {self.name} syncer.") - - message = None - author = self.bot.user - if ctx: - message = await ctx.send(f"📊 Synchronising {self.name}s.") - author = ctx.author - - diff = await self._get_diff(guild) - diff_dict = diff._asdict() # Ugly method for transforming the NamedTuple into a dict - totals = {k: len(v) for k, v in diff_dict.items() if v is not None} - diff_size = sum(totals.values()) - - confirmed, message = await self._get_confirmation_result(diff_size, author, message) - if not confirmed: - return - - # Preserve the core-dev role mention in the message edits so users aren't confused about - # where notifications came from. - mention = self._CORE_DEV_MENTION if author.bot else "" - - try: - await self._sync(diff) - except ResponseCodeError as e: - log.exception(f"{self.name} syncer failed!") - - # Don't show response text because it's probably some really long HTML. - results = f"status {e.status}\n```{e.response_json or 'See log output for details'}```" - content = f":x: {mention}Synchronisation of {self.name}s failed: {results}" - else: - results = ", ".join(f"{name} `{total}`" for name, total in totals.items()) - log.info(f"{self.name} syncer finished: {results}.") - content = f":ok_hand: {mention}Synchronisation of {self.name}s complete: {results}" - - if message: - await message.edit(content=content) - - -class RoleSyncer(Syncer): - """Synchronise the database with roles in the cache.""" - - name = "role" - - async def _get_diff(self, guild: Guild) -> _Diff: - """Return the difference of roles between the cache of `guild` and the database.""" - log.trace("Getting the diff for roles.") - roles = await self.bot.api_client.get('bot/roles') - - # Pack DB roles and guild roles into one common, hashable format. - # They're hashable so that they're easily comparable with sets later. - db_roles = {_Role(**role_dict) for role_dict in roles} - guild_roles = { - _Role( - id=role.id, - name=role.name, - colour=role.colour.value, - permissions=role.permissions.value, - position=role.position, - ) - for role in guild.roles - } - - guild_role_ids = {role.id for role in guild_roles} - api_role_ids = {role.id for role in db_roles} - new_role_ids = guild_role_ids - api_role_ids - deleted_role_ids = api_role_ids - guild_role_ids - - # New roles are those which are on the cached guild but not on the - # DB guild, going by the role ID. We need to send them in for creation. - roles_to_create = {role for role in guild_roles if role.id in new_role_ids} - roles_to_update = guild_roles - db_roles - roles_to_create - roles_to_delete = {role for role in db_roles if role.id in deleted_role_ids} - - return _Diff(roles_to_create, roles_to_update, roles_to_delete) - - async def _sync(self, diff: _Diff) -> None: - """Synchronise the database with the role cache of `guild`.""" - log.trace("Syncing created roles...") - for role in diff.created: - await self.bot.api_client.post('bot/roles', json=role._asdict()) - - log.trace("Syncing updated roles...") - for role in diff.updated: - await self.bot.api_client.put(f'bot/roles/{role.id}', json=role._asdict()) - - log.trace("Syncing deleted roles...") - for role in diff.deleted: - await self.bot.api_client.delete(f'bot/roles/{role.id}') - - -class UserSyncer(Syncer): - """Synchronise the database with users in the cache.""" - - name = "user" - - async def _get_diff(self, guild: Guild) -> _Diff: - """Return the difference of users between the cache of `guild` and the database.""" - log.trace("Getting the diff for users.") - users = await self.bot.api_client.get('bot/users') - - # Pack DB roles and guild roles into one common, hashable format. - # They're hashable so that they're easily comparable with sets later. - db_users = { - user_dict['id']: _User( - roles=tuple(sorted(user_dict.pop('roles'))), - **user_dict - ) - for user_dict in users - } - guild_users = { - member.id: _User( - id=member.id, - name=member.name, - discriminator=int(member.discriminator), - roles=tuple(sorted(role.id for role in member.roles)), - in_guild=True - ) - for member in guild.members - } - - users_to_create = set() - users_to_update = set() - - for db_user in db_users.values(): - guild_user = guild_users.get(db_user.id) - if guild_user is not None: - if db_user != guild_user: - users_to_update.add(guild_user) - - elif db_user.in_guild: - # The user is known in the DB but not the guild, and the - # DB currently specifies that the user is a member of the guild. - # This means that the user has left since the last sync. - # Update the `in_guild` attribute of the user on the site - # to signify that the user left. - new_api_user = db_user._replace(in_guild=False) - users_to_update.add(new_api_user) - - new_user_ids = set(guild_users.keys()) - set(db_users.keys()) - for user_id in new_user_ids: - # The user is known on the guild but not on the API. This means - # that the user has joined since the last sync. Create it. - new_user = guild_users[user_id] - users_to_create.add(new_user) - - return _Diff(users_to_create, users_to_update, None) - - async def _sync(self, diff: _Diff) -> None: - """Synchronise the database with the user cache of `guild`.""" - log.trace("Syncing created users...") - for user in diff.created: - await self.bot.api_client.post('bot/users', json=user._asdict()) - - log.trace("Syncing updated users...") - for user in diff.updated: - await self.bot.api_client.put(f'bot/users/{user.id}', json=user._asdict()) diff --git a/bot/cogs/moderation/__init__.py b/bot/cogs/moderation/__init__.py index aad1f3c26..e69de29bb 100644 --- a/bot/cogs/moderation/__init__.py +++ b/bot/cogs/moderation/__init__.py @@ -1,19 +0,0 @@ -from bot.bot import Bot -from .incidents import Incidents -from .infraction.infractions import Infractions -from .infraction.management import ModManagement -from .infraction.superstarify import Superstarify -from .modlog import ModLog -from .silence import Silence -from .slowmode import Slowmode - - -def setup(bot: Bot) -> None: - """Load the Incidents, Infractions, ModManagement, ModLog, Silence, Slowmode and Superstarify cogs.""" - bot.add_cog(Incidents(bot)) - bot.add_cog(Infractions(bot)) - bot.add_cog(ModLog(bot)) - bot.add_cog(ModManagement(bot)) - bot.add_cog(Silence(bot)) - bot.add_cog(Slowmode(bot)) - bot.add_cog(Superstarify(bot)) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 3605ab1d2..e49913552 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -405,3 +405,8 @@ class Incidents(Cog): """Pass `message` to `add_signals` if and only if it satisfies `is_incident`.""" if is_incident(message): await add_signals(message) + + +def setup(bot: Bot) -> None: + """Load the Incidents cog.""" + bot.add_cog(Incidents(bot)) diff --git a/bot/cogs/moderation/infraction/_scheduler.py b/bot/cogs/moderation/infraction/_scheduler.py new file mode 100644 index 000000000..33944a8db --- /dev/null +++ b/bot/cogs/moderation/infraction/_scheduler.py @@ -0,0 +1,463 @@ +import logging +import textwrap +import typing as t +from abc import abstractmethod +from datetime import datetime +from gettext import ngettext + +import dateutil.parser +import discord +from discord.ext.commands import Context + +from bot import constants +from bot.api import ResponseCodeError +from bot.bot import Bot +from bot.cogs.moderation.modlog import ModLog +from bot.constants import Colours, STAFF_CHANNELS +from bot.utils import time +from bot.utils.scheduling import Scheduler +from . import _utils +from ._utils import UserSnowflake + +log = logging.getLogger(__name__) + + +class InfractionScheduler: + """Handles the application, pardoning, and expiration of infractions.""" + + def __init__(self, bot: Bot, supported_infractions: t.Container[str]): + self.bot = bot + self.scheduler = Scheduler(self.__class__.__name__) + + self.bot.loop.create_task(self.reschedule_infractions(supported_infractions)) + + def cog_unload(self) -> None: + """Cancel scheduled tasks.""" + self.scheduler.cancel_all() + + @property + def mod_log(self) -> ModLog: + """Get the currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + async def reschedule_infractions(self, supported_infractions: t.Container[str]) -> None: + """Schedule expiration for previous infractions.""" + await self.bot.wait_until_guild_available() + + log.trace(f"Rescheduling infractions for {self.__class__.__name__}.") + + infractions = await self.bot.api_client.get( + 'bot/infractions', + params={'active': 'true'} + ) + for infraction in infractions: + if infraction["expires_at"] is not None and infraction["type"] in supported_infractions: + self.schedule_expiration(infraction) + + async def reapply_infraction( + self, + infraction: _utils.Infraction, + apply_coro: t.Optional[t.Awaitable] + ) -> None: + """Reapply an infraction if it's still active or deactivate it if less than 60 sec left.""" + # Calculate the time remaining, in seconds, for the mute. + expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) + delta = (expiry - datetime.utcnow()).total_seconds() + + # Mark as inactive if less than a minute remains. + if delta < 60: + log.info( + "Infraction will be deactivated instead of re-applied " + "because less than 1 minute remains." + ) + await self.deactivate_infraction(infraction) + return + + # Allowing mod log since this is a passive action that should be logged. + await apply_coro + log.info(f"Re-applied {infraction['type']} to user {infraction['user']} upon rejoining.") + + async def apply_infraction( + self, + ctx: Context, + infraction: _utils.Infraction, + user: UserSnowflake, + action_coro: t.Optional[t.Awaitable] = None + ) -> None: + """Apply an infraction to the user, log the infraction, and optionally notify the user.""" + infr_type = infraction["type"] + icon = _utils.INFRACTION_ICONS[infr_type][0] + reason = infraction["reason"] + expiry = time.format_infraction_with_duration(infraction["expires_at"]) + id_ = infraction['id'] + + log.trace(f"Applying {infr_type} infraction #{id_} to {user}.") + + # Default values for the confirmation message and mod log. + confirm_msg = ":ok_hand: applied" + + # Specifying an expiry for a note or warning makes no sense. + if infr_type in ("note", "warning"): + expiry_msg = "" + else: + expiry_msg = f" until {expiry}" if expiry else " permanently" + + dm_result = "" + dm_log_text = "" + expiry_log_text = f"\nExpires: {expiry}" if expiry else "" + log_title = "applied" + log_content = None + failed = False + + # DM the user about the infraction if it's not a shadow/hidden infraction. + # This needs to happen before we apply the infraction, as the bot cannot + # send DMs to user that it doesn't share a guild with. If we were to + # apply kick/ban infractions first, this would mean that we'd make it + # impossible for us to deliver a DM. See python-discord/bot#982. + if not infraction["hidden"]: + dm_result = f"{constants.Emojis.failmail} " + dm_log_text = "\nDM: **Failed**" + + # Sometimes user is a discord.Object; make it a proper user. + try: + if not isinstance(user, (discord.Member, discord.User)): + user = await self.bot.fetch_user(user.id) + except discord.HTTPException as e: + log.error(f"Failed to DM {user.id}: could not fetch user (status {e.status})") + else: + # Accordingly display whether the user was successfully notified via DM. + if await _utils.notify_infraction(user, infr_type, expiry, reason, icon): + dm_result = ":incoming_envelope: " + dm_log_text = "\nDM: Sent" + + end_msg = "" + if infraction["actor"] == self.bot.user.id: + log.trace( + f"Infraction #{id_} actor is bot; including the reason in the confirmation message." + ) + if reason: + end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})" + elif ctx.channel.id not in STAFF_CHANNELS: + log.trace( + f"Infraction #{id_} context is not in a staff channel; omitting infraction count." + ) + else: + log.trace(f"Fetching total infraction count for {user}.") + + infractions = await self.bot.api_client.get( + "bot/infractions", + params={"user__id": str(user.id)} + ) + total = len(infractions) + end_msg = f" ({total} infraction{ngettext('', 's', total)} total)" + + # Execute the necessary actions to apply the infraction on Discord. + if action_coro: + log.trace(f"Awaiting the infraction #{id_} application action coroutine.") + try: + await action_coro + if expiry: + # Schedule the expiration of the infraction. + self.schedule_expiration(infraction) + except discord.HTTPException as e: + # Accordingly display that applying the infraction failed. + confirm_msg = ":x: failed to apply" + expiry_msg = "" + log_content = ctx.author.mention + log_title = "failed to apply" + + log_msg = f"Failed to apply {infr_type} infraction #{id_} to {user}" + if isinstance(e, discord.Forbidden): + log.warning(f"{log_msg}: bot lacks permissions.") + else: + log.exception(log_msg) + failed = True + + if failed: + log.trace(f"Deleted infraction {infraction['id']} from database because applying infraction failed.") + try: + await self.bot.api_client.delete(f"bot/infractions/{id_}") + except ResponseCodeError as e: + confirm_msg += " and failed to delete" + log_title += " and failed to delete" + log.error(f"Deletion of {infr_type} infraction #{id_} failed with error code {e.status}.") + infr_message = "" + else: + infr_message = f" **{infr_type}** to {user.mention}{expiry_msg}{end_msg}" + + # Send a confirmation message to the invoking context. + log.trace(f"Sending infraction #{id_} confirmation message.") + await ctx.send(f"{dm_result}{confirm_msg}{infr_message}.") + + # Send a log message to the mod log. + log.trace(f"Sending apply mod log for infraction #{id_}.") + await self.mod_log.send_log_message( + icon_url=icon, + colour=Colours.soft_red, + title=f"Infraction {log_title}: {infr_type}", + thumbnail=user.avatar_url_as(static_format="png"), + text=textwrap.dedent(f""" + Member: {user.mention} (`{user.id}`) + Actor: {ctx.message.author}{dm_log_text}{expiry_log_text} + Reason: {reason} + """), + content=log_content, + footer=f"ID {infraction['id']}" + ) + + log.info(f"Applied {infr_type} infraction #{id_} to {user}.") + + async def pardon_infraction( + self, + ctx: Context, + infr_type: str, + user: UserSnowflake, + send_msg: bool = True + ) -> None: + """ + Prematurely end an infraction for a user and log the action in the mod log. + + If `send_msg` is True, then a pardoning confirmation message will be sent to + the context channel. Otherwise, no such message will be sent. + """ + log.trace(f"Pardoning {infr_type} infraction for {user}.") + + # Check the current active infraction + log.trace(f"Fetching active {infr_type} infractions for {user}.") + response = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': infr_type, + 'user__id': user.id + } + ) + + if not response: + log.debug(f"No active {infr_type} infraction found for {user}.") + await ctx.send(f":x: There's no active {infr_type} infraction for user {user.mention}.") + return + + # Deactivate the infraction and cancel its scheduled expiration task. + log_text = await self.deactivate_infraction(response[0], send_log=False) + + log_text["Member"] = f"{user.mention}(`{user.id}`)" + log_text["Actor"] = str(ctx.message.author) + log_content = None + id_ = response[0]['id'] + footer = f"ID: {id_}" + + # If multiple active infractions were found, mark them as inactive in the database + # and cancel their expiration tasks. + if len(response) > 1: + log.info( + f"Found more than one active {infr_type} infraction for user {user.id}; " + "deactivating the extra active infractions too." + ) + + footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}" + + log_note = f"Found multiple **active** {infr_type} infractions in the database." + if "Note" in log_text: + log_text["Note"] = f" {log_note}" + else: + log_text["Note"] = log_note + + # deactivate_infraction() is not called again because: + # 1. Discord cannot store multiple active bans or assign multiples of the same role + # 2. It would send a pardon DM for each active infraction, which is redundant + for infraction in response[1:]: + id_ = infraction['id'] + try: + # Mark infraction as inactive in the database. + await self.bot.api_client.patch( + f"bot/infractions/{id_}", + json={"active": False} + ) + except ResponseCodeError: + log.exception(f"Failed to deactivate infraction #{id_} ({infr_type})") + # This is simpler and cleaner than trying to concatenate all the errors. + log_text["Failure"] = "See bot's logs for details." + + # Cancel pending expiration task. + if infraction["expires_at"] is not None: + self.scheduler.cancel(infraction["id"]) + + # Accordingly display whether the user was successfully notified via DM. + dm_emoji = "" + if log_text.get("DM") == "Sent": + dm_emoji = ":incoming_envelope: " + elif "DM" in log_text: + dm_emoji = f"{constants.Emojis.failmail} " + + # Accordingly display whether the pardon failed. + if "Failure" in log_text: + confirm_msg = ":x: failed to pardon" + log_title = "pardon failed" + log_content = ctx.author.mention + + log.warning(f"Failed to pardon {infr_type} infraction #{id_} for {user}.") + else: + confirm_msg = ":ok_hand: pardoned" + log_title = "pardoned" + + log.info(f"Pardoned {infr_type} infraction #{id_} for {user}.") + + # Send a confirmation message to the invoking context. + if send_msg: + log.trace(f"Sending infraction #{id_} pardon confirmation message.") + await ctx.send( + f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. " + f"{log_text.get('Failure', '')}" + ) + + # Move reason to end of entry to avoid cutting out some keys + log_text["Reason"] = log_text.pop("Reason") + + # Send a log message to the mod log. + await self.mod_log.send_log_message( + icon_url=_utils.INFRACTION_ICONS[infr_type][1], + colour=Colours.soft_green, + title=f"Infraction {log_title}: {infr_type}", + thumbnail=user.avatar_url_as(static_format="png"), + text="\n".join(f"{k}: {v}" for k, v in log_text.items()), + footer=footer, + content=log_content, + ) + + async def deactivate_infraction( + self, + infraction: _utils.Infraction, + send_log: bool = True + ) -> t.Dict[str, str]: + """ + Deactivate an active infraction and return a dictionary of lines to send in a mod log. + + The infraction is removed from Discord, marked as inactive in the database, and has its + expiration task cancelled. If `send_log` is True, a mod log is sent for the + deactivation of the infraction. + + Infractions of unsupported types will raise a ValueError. + """ + guild = self.bot.get_guild(constants.Guild.id) + mod_role = guild.get_role(constants.Roles.moderators) + user_id = infraction["user"] + actor = infraction["actor"] + type_ = infraction["type"] + id_ = infraction["id"] + inserted_at = infraction["inserted_at"] + expiry = infraction["expires_at"] + + log.info(f"Marking infraction #{id_} as inactive (expired).") + + expiry = dateutil.parser.isoparse(expiry).replace(tzinfo=None) if expiry else None + created = time.format_infraction_with_duration(inserted_at, expiry) + + log_content = None + log_text = { + "Member": f"<@{user_id}>", + "Actor": str(self.bot.get_user(actor) or actor), + "Reason": infraction["reason"], + "Created": created, + } + + try: + log.trace("Awaiting the pardon action coroutine.") + returned_log = await self._pardon_action(infraction) + + if returned_log is not None: + log_text = {**log_text, **returned_log} # Merge the logs together + else: + raise ValueError( + f"Attempted to deactivate an unsupported infraction #{id_} ({type_})!" + ) + except discord.Forbidden: + log.warning(f"Failed to deactivate infraction #{id_} ({type_}): bot lacks permissions.") + log_text["Failure"] = "The bot lacks permissions to do this (role hierarchy?)" + log_content = mod_role.mention + except discord.HTTPException as e: + log.exception(f"Failed to deactivate infraction #{id_} ({type_})") + log_text["Failure"] = f"HTTPException with status {e.status} and code {e.code}." + log_content = mod_role.mention + + # Check if the user is currently being watched by Big Brother. + try: + log.trace(f"Determining if user {user_id} is currently being watched by Big Brother.") + + active_watch = await self.bot.api_client.get( + "bot/infractions", + params={ + "active": "true", + "type": "watch", + "user__id": user_id + } + ) + + log_text["Watching"] = "Yes" if active_watch else "No" + except ResponseCodeError: + log.exception(f"Failed to fetch watch status for user {user_id}") + log_text["Watching"] = "Unknown - failed to fetch watch status." + + try: + # Mark infraction as inactive in the database. + log.trace(f"Marking infraction #{id_} as inactive in the database.") + await self.bot.api_client.patch( + f"bot/infractions/{id_}", + json={"active": False} + ) + except ResponseCodeError as e: + log.exception(f"Failed to deactivate infraction #{id_} ({type_})") + log_line = f"API request failed with code {e.status}." + log_content = mod_role.mention + + # Append to an existing failure message if possible + if "Failure" in log_text: + log_text["Failure"] += f" {log_line}" + else: + log_text["Failure"] = log_line + + # Cancel the expiration task. + if infraction["expires_at"] is not None: + self.scheduler.cancel(infraction["id"]) + + # Send a log message to the mod log. + if send_log: + log_title = "expiration failed" if "Failure" in log_text else "expired" + + user = self.bot.get_user(user_id) + avatar = user.avatar_url_as(static_format="png") if user else None + + # Move reason to end so when reason is too long, this is not gonna cut out required items. + log_text["Reason"] = log_text.pop("Reason") + + log.trace(f"Sending deactivation mod log for infraction #{id_}.") + await self.mod_log.send_log_message( + icon_url=_utils.INFRACTION_ICONS[type_][1], + colour=Colours.soft_green, + title=f"Infraction {log_title}: {type_}", + thumbnail=avatar, + text="\n".join(f"{k}: {v}" for k, v in log_text.items()), + footer=f"ID: {id_}", + content=log_content, + ) + + return log_text + + @abstractmethod + async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]: + """ + Execute deactivation steps specific to the infraction's type and return a log dict. + + If an infraction type is unsupported, return None instead. + """ + raise NotImplementedError + + def schedule_expiration(self, infraction: _utils.Infraction) -> None: + """ + Marks an infraction expired after the delay from time of scheduling to time of expiration. + + At the time of expiration, the infraction is marked as inactive on the website and the + expiration task is cancelled. + """ + expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) + self.scheduler.schedule_at(expiry, infraction["id"], self.deactivate_infraction(infraction)) diff --git a/bot/cogs/moderation/infraction/_utils.py b/bot/cogs/moderation/infraction/_utils.py new file mode 100644 index 000000000..fb55287b6 --- /dev/null +++ b/bot/cogs/moderation/infraction/_utils.py @@ -0,0 +1,201 @@ +import logging +import textwrap +import typing as t +from datetime import datetime + +import discord +from discord.ext.commands import Context + +from bot.api import ResponseCodeError +from bot.constants import Colours, Icons + +log = logging.getLogger(__name__) + +# apply icon, pardon icon +INFRACTION_ICONS = { + "ban": (Icons.user_ban, Icons.user_unban), + "kick": (Icons.sign_out, None), + "mute": (Icons.user_mute, Icons.user_unmute), + "note": (Icons.user_warn, None), + "superstar": (Icons.superstarify, Icons.unsuperstarify), + "warning": (Icons.user_warn, None), +} +RULES_URL = "https://pythondiscord.com/pages/rules" +APPEALABLE_INFRACTIONS = ("ban", "mute") + +# Type aliases +UserObject = t.Union[discord.Member, discord.User] +UserSnowflake = t.Union[UserObject, discord.Object] +Infraction = t.Dict[str, t.Union[str, int, bool]] + + +async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]: + """ + Create a new user in the database. + + Used when an infraction needs to be applied on a user absent in the guild. + """ + log.trace(f"Attempting to add user {user.id} to the database.") + + if not isinstance(user, (discord.Member, discord.User)): + log.debug("The user being added to the DB is not a Member or User object.") + + payload = { + 'discriminator': int(getattr(user, 'discriminator', 0)), + 'id': user.id, + 'in_guild': False, + 'name': getattr(user, 'name', 'Name unknown'), + 'roles': [] + } + + try: + response = await ctx.bot.api_client.post('bot/users', json=payload) + log.info(f"User {user.id} added to the DB.") + return response + except ResponseCodeError as e: + log.error(f"Failed to add user {user.id} to the DB. {e}") + await ctx.send(f":x: The attempt to add the user to the DB failed: status {e.status}") + + +async def post_infraction( + ctx: Context, + user: UserSnowflake, + infr_type: str, + reason: str, + expires_at: datetime = None, + hidden: bool = False, + active: bool = True +) -> t.Optional[dict]: + """Posts an infraction to the API.""" + log.trace(f"Posting {infr_type} infraction for {user} to the API.") + + payload = { + "actor": ctx.message.author.id, + "hidden": hidden, + "reason": reason, + "type": infr_type, + "user": user.id, + "active": active + } + if expires_at: + payload['expires_at'] = expires_at.isoformat() + + # Try to apply the infraction. If it fails because the user doesn't exist, try to add it. + for should_post_user in (True, False): + try: + response = await ctx.bot.api_client.post('bot/infractions', json=payload) + return response + except ResponseCodeError as e: + if e.status == 400 and 'user' in e.response_json: + # Only one attempt to add the user to the database, not two: + if not should_post_user or await post_user(ctx, user) is None: + return + else: + log.exception(f"Unexpected error while adding an infraction for {user}:") + await ctx.send(f":x: There was an error adding the infraction: status {e.status}.") + return + + +async def get_active_infraction( + ctx: Context, + user: UserSnowflake, + infr_type: str, + send_msg: bool = True +) -> t.Optional[dict]: + """ + Retrieves an active infraction of the given type for the user. + + If `send_msg` is True and the user has an active infraction matching the `infr_type` parameter, + then a message for the moderator will be sent to the context channel letting them know. + Otherwise, no message will be sent. + """ + log.trace(f"Checking if {user} has active infractions of type {infr_type}.") + + active_infractions = await ctx.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': infr_type, + 'user__id': str(user.id) + } + ) + if active_infractions: + # Checks to see if the moderator should be told there is an active infraction + if send_msg: + log.trace(f"{user} has active infractions of type {infr_type}.") + await ctx.send( + f":x: According to my records, this user already has a {infr_type} infraction. " + f"See infraction **#{active_infractions[0]['id']}**." + ) + return active_infractions[0] + else: + log.trace(f"{user} does not have active infractions of type {infr_type}.") + + +async def notify_infraction( + user: UserObject, + infr_type: str, + expires_at: t.Optional[str] = None, + reason: t.Optional[str] = None, + icon_url: str = Icons.token_removed +) -> bool: + """DM a user about their new infraction and return True if the DM is successful.""" + log.trace(f"Sending {user} a DM about their {infr_type} infraction.") + + text = textwrap.dedent(f""" + **Type:** {infr_type.capitalize()} + **Expires:** {expires_at or "N/A"} + **Reason:** {reason or "No reason provided."} + """) + + embed = discord.Embed( + description=textwrap.shorten(text, width=2048, placeholder="..."), + colour=Colours.soft_red + ) + + embed.set_author(name="Infraction information", icon_url=icon_url, url=RULES_URL) + embed.title = f"Please review our rules over at {RULES_URL}" + embed.url = RULES_URL + + if infr_type in APPEALABLE_INFRACTIONS: + embed.set_footer( + text="To appeal this infraction, send an e-mail to appeals@pythondiscord.com" + ) + + return await send_private_embed(user, embed) + + +async def notify_pardon( + user: UserObject, + title: str, + content: str, + icon_url: str = Icons.user_verified +) -> bool: + """DM a user about their pardoned infraction and return True if the DM is successful.""" + log.trace(f"Sending {user} a DM about their pardoned infraction.") + + embed = discord.Embed( + description=content, + colour=Colours.soft_green + ) + + embed.set_author(name=title, icon_url=icon_url) + + return await send_private_embed(user, embed) + + +async def send_private_embed(user: UserObject, embed: discord.Embed) -> bool: + """ + A helper method for sending an embed to a user's DMs. + + Returns a boolean indicator of DM success. + """ + try: + await user.send(embed=embed) + return True + except (discord.HTTPException, discord.Forbidden, discord.NotFound): + log.debug( + f"Infraction-related information could not be sent to user {user} ({user.id}). " + "The user either could not be retrieved or probably disabled their DMs." + ) + return False diff --git a/bot/cogs/moderation/infraction/infractions.py b/bot/cogs/moderation/infraction/infractions.py index 8df642428..cb459b447 100644 --- a/bot/cogs/moderation/infraction/infractions.py +++ b/bot/cogs/moderation/infraction/infractions.py @@ -13,9 +13,9 @@ from bot.constants import Event from bot.converters import Expiry, FetchedMember from bot.decorators import respect_role_hierarchy from bot.utils.checks import with_role_check -from . import utils -from .scheduler import InfractionScheduler -from .utils import UserSnowflake +from . import _utils +from ._scheduler import InfractionScheduler +from ._utils import UserSnowflake log = logging.getLogger(__name__) @@ -55,7 +55,7 @@ class Infractions(InfractionScheduler, commands.Cog): @command() async def warn(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: """Warn a user for the given reason.""" - infraction = await utils.post_infraction(ctx, user, "warning", reason, active=False) + infraction = await _utils.post_infraction(ctx, user, "warning", reason, active=False) if infraction is None: return @@ -125,7 +125,7 @@ class Infractions(InfractionScheduler, commands.Cog): @command(hidden=True) async def note(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: """Create a private note for a user with the given reason without notifying the user.""" - infraction = await utils.post_infraction(ctx, user, "note", reason, hidden=True, active=False) + infraction = await _utils.post_infraction(ctx, user, "note", reason, hidden=True, active=False) if infraction is None: return @@ -213,10 +213,10 @@ class Infractions(InfractionScheduler, commands.Cog): async def apply_mute(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None: """Apply a mute infraction with kwargs passed to `post_infraction`.""" - if await utils.get_active_infraction(ctx, user, "mute"): + if await _utils.get_active_infraction(ctx, user, "mute"): return - infraction = await utils.post_infraction(ctx, user, "mute", reason, active=True, **kwargs) + infraction = await _utils.post_infraction(ctx, user, "mute", reason, active=True, **kwargs) if infraction is None: return @@ -233,7 +233,7 @@ class Infractions(InfractionScheduler, commands.Cog): @respect_role_hierarchy() async def apply_kick(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None: """Apply a kick infraction with kwargs passed to `post_infraction`.""" - infraction = await utils.post_infraction(ctx, user, "kick", reason, active=False, **kwargs) + infraction = await _utils.post_infraction(ctx, user, "kick", reason, active=False, **kwargs) if infraction is None: return @@ -254,7 +254,7 @@ class Infractions(InfractionScheduler, commands.Cog): """ # In the case of a permanent ban, we don't need get_active_infractions to tell us if one is active is_temporary = kwargs.get("expires_at") is not None - active_infraction = await utils.get_active_infraction(ctx, user, "ban", is_temporary) + active_infraction = await _utils.get_active_infraction(ctx, user, "ban", is_temporary) if active_infraction: if is_temporary: @@ -269,7 +269,7 @@ class Infractions(InfractionScheduler, commands.Cog): log.trace("Old tempban is being replaced by new permaban.") await self.pardon_infraction(ctx, "ban", user, is_temporary) - infraction = await utils.post_infraction(ctx, user, "ban", reason, active=True, **kwargs) + infraction = await _utils.post_infraction(ctx, user, "ban", reason, active=True, **kwargs) if infraction is None: return @@ -309,11 +309,11 @@ class Infractions(InfractionScheduler, commands.Cog): await user.remove_roles(self._muted_role, reason=reason) # DM the user about the expiration. - notified = await utils.notify_pardon( + notified = await _utils.notify_pardon( user=user, title="You have been unmuted", content="You may now send messages in the server.", - icon_url=utils.INFRACTION_ICONS["mute"][1] + icon_url=_utils.INFRACTION_ICONS["mute"][1] ) log_text["Member"] = f"{user.mention}(`{user.id}`)" @@ -339,7 +339,7 @@ class Infractions(InfractionScheduler, commands.Cog): return log_text - async def _pardon_action(self, infraction: utils.Infraction) -> t.Optional[t.Dict[str, str]]: + async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]: """ Execute deactivation steps specific to the infraction's type and return a log dict. @@ -368,3 +368,8 @@ class Infractions(InfractionScheduler, commands.Cog): if discord.User in error.converters or discord.Member in error.converters: await ctx.send(str(error.errors[0])) error.handled = True + + +def setup(bot: Bot) -> None: + """Load the Infractions cog.""" + bot.add_cog(Infractions(bot)) diff --git a/bot/cogs/moderation/infraction/management.py b/bot/cogs/moderation/infraction/management.py index 791585b6e..9e7ae8113 100644 --- a/bot/cogs/moderation/infraction/management.py +++ b/bot/cogs/moderation/infraction/management.py @@ -14,7 +14,7 @@ from bot.converters import Expiry, InfractionSearchQuery, allowed_strings, proxy from bot.pagination import LinePaginator from bot.utils import time from bot.utils.checks import in_whitelist_check, with_role_check -from . import utils +from . import _utils from .infractions import Infractions log = logging.getLogger(__name__) @@ -220,7 +220,7 @@ class ModManagement(commands.Cog): self, ctx: Context, embed: discord.Embed, - infractions: t.Iterable[utils.Infraction] + infractions: t.Iterable[_utils.Infraction] ) -> None: """Send a paginated embed of infractions for the specified user.""" if not infractions: @@ -241,7 +241,7 @@ class ModManagement(commands.Cog): max_size=1000 ) - def infraction_to_string(self, infraction: utils.Infraction) -> str: + def infraction_to_string(self, infraction: _utils.Infraction) -> str: """Convert the infraction object to a string representation.""" actor_id = infraction["actor"] guild = self.bot.get_guild(constants.Guild.id) @@ -303,3 +303,8 @@ class ModManagement(commands.Cog): if discord.User in error.converters: await ctx.send(str(error.errors[0])) error.handled = True + + +def setup(bot: Bot) -> None: + """Load the ModManagement cog.""" + bot.add_cog(ModManagement(bot)) diff --git a/bot/cogs/moderation/infraction/scheduler.py b/bot/cogs/moderation/infraction/scheduler.py deleted file mode 100644 index b3d27fe76..000000000 --- a/bot/cogs/moderation/infraction/scheduler.py +++ /dev/null @@ -1,463 +0,0 @@ -import logging -import textwrap -import typing as t -from abc import abstractmethod -from datetime import datetime -from gettext import ngettext - -import dateutil.parser -import discord -from discord.ext.commands import Context - -from bot import constants -from bot.api import ResponseCodeError -from bot.bot import Bot -from bot.cogs.moderation.modlog import ModLog -from bot.constants import Colours, STAFF_CHANNELS -from bot.utils import time -from bot.utils.scheduling import Scheduler -from . import utils -from .utils import UserSnowflake - -log = logging.getLogger(__name__) - - -class InfractionScheduler: - """Handles the application, pardoning, and expiration of infractions.""" - - def __init__(self, bot: Bot, supported_infractions: t.Container[str]): - self.bot = bot - self.scheduler = Scheduler(self.__class__.__name__) - - self.bot.loop.create_task(self.reschedule_infractions(supported_infractions)) - - def cog_unload(self) -> None: - """Cancel scheduled tasks.""" - self.scheduler.cancel_all() - - @property - def mod_log(self) -> ModLog: - """Get the currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - async def reschedule_infractions(self, supported_infractions: t.Container[str]) -> None: - """Schedule expiration for previous infractions.""" - await self.bot.wait_until_guild_available() - - log.trace(f"Rescheduling infractions for {self.__class__.__name__}.") - - infractions = await self.bot.api_client.get( - 'bot/infractions', - params={'active': 'true'} - ) - for infraction in infractions: - if infraction["expires_at"] is not None and infraction["type"] in supported_infractions: - self.schedule_expiration(infraction) - - async def reapply_infraction( - self, - infraction: utils.Infraction, - apply_coro: t.Optional[t.Awaitable] - ) -> None: - """Reapply an infraction if it's still active or deactivate it if less than 60 sec left.""" - # Calculate the time remaining, in seconds, for the mute. - expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) - delta = (expiry - datetime.utcnow()).total_seconds() - - # Mark as inactive if less than a minute remains. - if delta < 60: - log.info( - "Infraction will be deactivated instead of re-applied " - "because less than 1 minute remains." - ) - await self.deactivate_infraction(infraction) - return - - # Allowing mod log since this is a passive action that should be logged. - await apply_coro - log.info(f"Re-applied {infraction['type']} to user {infraction['user']} upon rejoining.") - - async def apply_infraction( - self, - ctx: Context, - infraction: utils.Infraction, - user: UserSnowflake, - action_coro: t.Optional[t.Awaitable] = None - ) -> None: - """Apply an infraction to the user, log the infraction, and optionally notify the user.""" - infr_type = infraction["type"] - icon = utils.INFRACTION_ICONS[infr_type][0] - reason = infraction["reason"] - expiry = time.format_infraction_with_duration(infraction["expires_at"]) - id_ = infraction['id'] - - log.trace(f"Applying {infr_type} infraction #{id_} to {user}.") - - # Default values for the confirmation message and mod log. - confirm_msg = ":ok_hand: applied" - - # Specifying an expiry for a note or warning makes no sense. - if infr_type in ("note", "warning"): - expiry_msg = "" - else: - expiry_msg = f" until {expiry}" if expiry else " permanently" - - dm_result = "" - dm_log_text = "" - expiry_log_text = f"\nExpires: {expiry}" if expiry else "" - log_title = "applied" - log_content = None - failed = False - - # DM the user about the infraction if it's not a shadow/hidden infraction. - # This needs to happen before we apply the infraction, as the bot cannot - # send DMs to user that it doesn't share a guild with. If we were to - # apply kick/ban infractions first, this would mean that we'd make it - # impossible for us to deliver a DM. See python-discord/bot#982. - if not infraction["hidden"]: - dm_result = f"{constants.Emojis.failmail} " - dm_log_text = "\nDM: **Failed**" - - # Sometimes user is a discord.Object; make it a proper user. - try: - if not isinstance(user, (discord.Member, discord.User)): - user = await self.bot.fetch_user(user.id) - except discord.HTTPException as e: - log.error(f"Failed to DM {user.id}: could not fetch user (status {e.status})") - else: - # Accordingly display whether the user was successfully notified via DM. - if await utils.notify_infraction(user, infr_type, expiry, reason, icon): - dm_result = ":incoming_envelope: " - dm_log_text = "\nDM: Sent" - - end_msg = "" - if infraction["actor"] == self.bot.user.id: - log.trace( - f"Infraction #{id_} actor is bot; including the reason in the confirmation message." - ) - if reason: - end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})" - elif ctx.channel.id not in STAFF_CHANNELS: - log.trace( - f"Infraction #{id_} context is not in a staff channel; omitting infraction count." - ) - else: - log.trace(f"Fetching total infraction count for {user}.") - - infractions = await self.bot.api_client.get( - "bot/infractions", - params={"user__id": str(user.id)} - ) - total = len(infractions) - end_msg = f" ({total} infraction{ngettext('', 's', total)} total)" - - # Execute the necessary actions to apply the infraction on Discord. - if action_coro: - log.trace(f"Awaiting the infraction #{id_} application action coroutine.") - try: - await action_coro - if expiry: - # Schedule the expiration of the infraction. - self.schedule_expiration(infraction) - except discord.HTTPException as e: - # Accordingly display that applying the infraction failed. - confirm_msg = ":x: failed to apply" - expiry_msg = "" - log_content = ctx.author.mention - log_title = "failed to apply" - - log_msg = f"Failed to apply {infr_type} infraction #{id_} to {user}" - if isinstance(e, discord.Forbidden): - log.warning(f"{log_msg}: bot lacks permissions.") - else: - log.exception(log_msg) - failed = True - - if failed: - log.trace(f"Deleted infraction {infraction['id']} from database because applying infraction failed.") - try: - await self.bot.api_client.delete(f"bot/infractions/{id_}") - except ResponseCodeError as e: - confirm_msg += " and failed to delete" - log_title += " and failed to delete" - log.error(f"Deletion of {infr_type} infraction #{id_} failed with error code {e.status}.") - infr_message = "" - else: - infr_message = f" **{infr_type}** to {user.mention}{expiry_msg}{end_msg}" - - # Send a confirmation message to the invoking context. - log.trace(f"Sending infraction #{id_} confirmation message.") - await ctx.send(f"{dm_result}{confirm_msg}{infr_message}.") - - # Send a log message to the mod log. - log.trace(f"Sending apply mod log for infraction #{id_}.") - await self.mod_log.send_log_message( - icon_url=icon, - colour=Colours.soft_red, - title=f"Infraction {log_title}: {infr_type}", - thumbnail=user.avatar_url_as(static_format="png"), - text=textwrap.dedent(f""" - Member: {user.mention} (`{user.id}`) - Actor: {ctx.message.author}{dm_log_text}{expiry_log_text} - Reason: {reason} - """), - content=log_content, - footer=f"ID {infraction['id']}" - ) - - log.info(f"Applied {infr_type} infraction #{id_} to {user}.") - - async def pardon_infraction( - self, - ctx: Context, - infr_type: str, - user: UserSnowflake, - send_msg: bool = True - ) -> None: - """ - Prematurely end an infraction for a user and log the action in the mod log. - - If `send_msg` is True, then a pardoning confirmation message will be sent to - the context channel. Otherwise, no such message will be sent. - """ - log.trace(f"Pardoning {infr_type} infraction for {user}.") - - # Check the current active infraction - log.trace(f"Fetching active {infr_type} infractions for {user}.") - response = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': infr_type, - 'user__id': user.id - } - ) - - if not response: - log.debug(f"No active {infr_type} infraction found for {user}.") - await ctx.send(f":x: There's no active {infr_type} infraction for user {user.mention}.") - return - - # Deactivate the infraction and cancel its scheduled expiration task. - log_text = await self.deactivate_infraction(response[0], send_log=False) - - log_text["Member"] = f"{user.mention}(`{user.id}`)" - log_text["Actor"] = str(ctx.message.author) - log_content = None - id_ = response[0]['id'] - footer = f"ID: {id_}" - - # If multiple active infractions were found, mark them as inactive in the database - # and cancel their expiration tasks. - if len(response) > 1: - log.info( - f"Found more than one active {infr_type} infraction for user {user.id}; " - "deactivating the extra active infractions too." - ) - - footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}" - - log_note = f"Found multiple **active** {infr_type} infractions in the database." - if "Note" in log_text: - log_text["Note"] = f" {log_note}" - else: - log_text["Note"] = log_note - - # deactivate_infraction() is not called again because: - # 1. Discord cannot store multiple active bans or assign multiples of the same role - # 2. It would send a pardon DM for each active infraction, which is redundant - for infraction in response[1:]: - id_ = infraction['id'] - try: - # Mark infraction as inactive in the database. - await self.bot.api_client.patch( - f"bot/infractions/{id_}", - json={"active": False} - ) - except ResponseCodeError: - log.exception(f"Failed to deactivate infraction #{id_} ({infr_type})") - # This is simpler and cleaner than trying to concatenate all the errors. - log_text["Failure"] = "See bot's logs for details." - - # Cancel pending expiration task. - if infraction["expires_at"] is not None: - self.scheduler.cancel(infraction["id"]) - - # Accordingly display whether the user was successfully notified via DM. - dm_emoji = "" - if log_text.get("DM") == "Sent": - dm_emoji = ":incoming_envelope: " - elif "DM" in log_text: - dm_emoji = f"{constants.Emojis.failmail} " - - # Accordingly display whether the pardon failed. - if "Failure" in log_text: - confirm_msg = ":x: failed to pardon" - log_title = "pardon failed" - log_content = ctx.author.mention - - log.warning(f"Failed to pardon {infr_type} infraction #{id_} for {user}.") - else: - confirm_msg = ":ok_hand: pardoned" - log_title = "pardoned" - - log.info(f"Pardoned {infr_type} infraction #{id_} for {user}.") - - # Send a confirmation message to the invoking context. - if send_msg: - log.trace(f"Sending infraction #{id_} pardon confirmation message.") - await ctx.send( - f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. " - f"{log_text.get('Failure', '')}" - ) - - # Move reason to end of entry to avoid cutting out some keys - log_text["Reason"] = log_text.pop("Reason") - - # Send a log message to the mod log. - await self.mod_log.send_log_message( - icon_url=utils.INFRACTION_ICONS[infr_type][1], - colour=Colours.soft_green, - title=f"Infraction {log_title}: {infr_type}", - thumbnail=user.avatar_url_as(static_format="png"), - text="\n".join(f"{k}: {v}" for k, v in log_text.items()), - footer=footer, - content=log_content, - ) - - async def deactivate_infraction( - self, - infraction: utils.Infraction, - send_log: bool = True - ) -> t.Dict[str, str]: - """ - Deactivate an active infraction and return a dictionary of lines to send in a mod log. - - The infraction is removed from Discord, marked as inactive in the database, and has its - expiration task cancelled. If `send_log` is True, a mod log is sent for the - deactivation of the infraction. - - Infractions of unsupported types will raise a ValueError. - """ - guild = self.bot.get_guild(constants.Guild.id) - mod_role = guild.get_role(constants.Roles.moderators) - user_id = infraction["user"] - actor = infraction["actor"] - type_ = infraction["type"] - id_ = infraction["id"] - inserted_at = infraction["inserted_at"] - expiry = infraction["expires_at"] - - log.info(f"Marking infraction #{id_} as inactive (expired).") - - expiry = dateutil.parser.isoparse(expiry).replace(tzinfo=None) if expiry else None - created = time.format_infraction_with_duration(inserted_at, expiry) - - log_content = None - log_text = { - "Member": f"<@{user_id}>", - "Actor": str(self.bot.get_user(actor) or actor), - "Reason": infraction["reason"], - "Created": created, - } - - try: - log.trace("Awaiting the pardon action coroutine.") - returned_log = await self._pardon_action(infraction) - - if returned_log is not None: - log_text = {**log_text, **returned_log} # Merge the logs together - else: - raise ValueError( - f"Attempted to deactivate an unsupported infraction #{id_} ({type_})!" - ) - except discord.Forbidden: - log.warning(f"Failed to deactivate infraction #{id_} ({type_}): bot lacks permissions.") - log_text["Failure"] = "The bot lacks permissions to do this (role hierarchy?)" - log_content = mod_role.mention - except discord.HTTPException as e: - log.exception(f"Failed to deactivate infraction #{id_} ({type_})") - log_text["Failure"] = f"HTTPException with status {e.status} and code {e.code}." - log_content = mod_role.mention - - # Check if the user is currently being watched by Big Brother. - try: - log.trace(f"Determining if user {user_id} is currently being watched by Big Brother.") - - active_watch = await self.bot.api_client.get( - "bot/infractions", - params={ - "active": "true", - "type": "watch", - "user__id": user_id - } - ) - - log_text["Watching"] = "Yes" if active_watch else "No" - except ResponseCodeError: - log.exception(f"Failed to fetch watch status for user {user_id}") - log_text["Watching"] = "Unknown - failed to fetch watch status." - - try: - # Mark infraction as inactive in the database. - log.trace(f"Marking infraction #{id_} as inactive in the database.") - await self.bot.api_client.patch( - f"bot/infractions/{id_}", - json={"active": False} - ) - except ResponseCodeError as e: - log.exception(f"Failed to deactivate infraction #{id_} ({type_})") - log_line = f"API request failed with code {e.status}." - log_content = mod_role.mention - - # Append to an existing failure message if possible - if "Failure" in log_text: - log_text["Failure"] += f" {log_line}" - else: - log_text["Failure"] = log_line - - # Cancel the expiration task. - if infraction["expires_at"] is not None: - self.scheduler.cancel(infraction["id"]) - - # Send a log message to the mod log. - if send_log: - log_title = "expiration failed" if "Failure" in log_text else "expired" - - user = self.bot.get_user(user_id) - avatar = user.avatar_url_as(static_format="png") if user else None - - # Move reason to end so when reason is too long, this is not gonna cut out required items. - log_text["Reason"] = log_text.pop("Reason") - - log.trace(f"Sending deactivation mod log for infraction #{id_}.") - await self.mod_log.send_log_message( - icon_url=utils.INFRACTION_ICONS[type_][1], - colour=Colours.soft_green, - title=f"Infraction {log_title}: {type_}", - thumbnail=avatar, - text="\n".join(f"{k}: {v}" for k, v in log_text.items()), - footer=f"ID: {id_}", - content=log_content, - ) - - return log_text - - @abstractmethod - async def _pardon_action(self, infraction: utils.Infraction) -> t.Optional[t.Dict[str, str]]: - """ - Execute deactivation steps specific to the infraction's type and return a log dict. - - If an infraction type is unsupported, return None instead. - """ - raise NotImplementedError - - def schedule_expiration(self, infraction: utils.Infraction) -> None: - """ - Marks an infraction expired after the delay from time of scheduling to time of expiration. - - At the time of expiration, the infraction is marked as inactive on the website and the - expiration task is cancelled. - """ - expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) - self.scheduler.schedule_at(expiry, infraction["id"], self.deactivate_infraction(infraction)) diff --git a/bot/cogs/moderation/infraction/superstarify.py b/bot/cogs/moderation/infraction/superstarify.py index 867de815a..7dc5b4691 100644 --- a/bot/cogs/moderation/infraction/superstarify.py +++ b/bot/cogs/moderation/infraction/superstarify.py @@ -13,8 +13,8 @@ from bot.bot import Bot from bot.converters import Expiry from bot.utils.checks import with_role_check from bot.utils.time import format_infraction -from . import utils -from .scheduler import InfractionScheduler +from . import _utils +from ._scheduler import InfractionScheduler log = logging.getLogger(__name__) NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#nickname-policy" @@ -67,7 +67,7 @@ class Superstarify(InfractionScheduler, Cog): reason=f"Superstarified member tried to escape the prison: {infraction['id']}" ) - notified = await utils.notify_infraction( + notified = await _utils.notify_infraction( user=after, infr_type="Superstarify", expires_at=format_infraction(infraction["expires_at"]), @@ -76,7 +76,7 @@ class Superstarify(InfractionScheduler, Cog): f"from **{before.display_name}** to **{after.display_name}**, but as you " "are currently in superstar-prison, you do not have permission to do so." ), - icon_url=utils.INFRACTION_ICONS["superstar"][0] + icon_url=_utils.INFRACTION_ICONS["superstar"][0] ) if not notified: @@ -130,12 +130,12 @@ class Superstarify(InfractionScheduler, Cog): An optional reason can be provided. If no reason is given, the original name will be shown in a generated reason. """ - if await utils.get_active_infraction(ctx, member, "superstar"): + if await _utils.get_active_infraction(ctx, member, "superstar"): return # Post the infraction to the API reason = reason or f"old nick: {member.display_name}" - infraction = await utils.post_infraction(ctx, member, "superstar", reason, duration, active=True) + infraction = await _utils.post_infraction(ctx, member, "superstar", reason, duration, active=True) id_ = infraction["id"] old_nick = member.display_name @@ -149,11 +149,11 @@ class Superstarify(InfractionScheduler, Cog): self.schedule_expiration(infraction) # Send a DM to the user to notify them of their new infraction. - await utils.notify_infraction( + await _utils.notify_infraction( user=member, infr_type="Superstarify", expires_at=expiry_str, - icon_url=utils.INFRACTION_ICONS["superstar"][0], + icon_url=_utils.INFRACTION_ICONS["superstar"][0], reason=f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL})." ) @@ -176,7 +176,7 @@ class Superstarify(InfractionScheduler, Cog): # Log to the mod log channel. log.trace(f"Sending apply mod log for superstar #{id_}.") await self.mod_log.send_log_message( - icon_url=utils.INFRACTION_ICONS["superstar"][0], + icon_url=_utils.INFRACTION_ICONS["superstar"][0], colour=Colour.gold(), title="Member achieved superstardom", thumbnail=member.avatar_url_as(static_format="png"), @@ -196,7 +196,7 @@ class Superstarify(InfractionScheduler, Cog): """Remove the superstarify infraction and allow the user to change their nickname.""" await self.pardon_infraction(ctx, "superstar", member) - async def _pardon_action(self, infraction: utils.Infraction) -> t.Optional[t.Dict[str, str]]: + async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]: """Pardon a superstar infraction and return a log dict.""" if infraction["type"] != "superstar": return @@ -213,11 +213,11 @@ class Superstarify(InfractionScheduler, Cog): return {} # DM the user about the expiration. - notified = await utils.notify_pardon( + notified = await _utils.notify_pardon( user=user, title="You are no longer superstarified", content="You may now change your nickname on the server.", - icon_url=utils.INFRACTION_ICONS["superstar"][1] + icon_url=_utils.INFRACTION_ICONS["superstar"][1] ) return { @@ -237,3 +237,8 @@ class Superstarify(InfractionScheduler, Cog): def cog_check(self, ctx: Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" return with_role_check(ctx, *constants.MODERATION_ROLES) + + +def setup(bot: Bot) -> None: + """Load the Superstarify cog.""" + bot.add_cog(Superstarify(bot)) diff --git a/bot/cogs/moderation/infraction/utils.py b/bot/cogs/moderation/infraction/utils.py deleted file mode 100644 index fb55287b6..000000000 --- a/bot/cogs/moderation/infraction/utils.py +++ /dev/null @@ -1,201 +0,0 @@ -import logging -import textwrap -import typing as t -from datetime import datetime - -import discord -from discord.ext.commands import Context - -from bot.api import ResponseCodeError -from bot.constants import Colours, Icons - -log = logging.getLogger(__name__) - -# apply icon, pardon icon -INFRACTION_ICONS = { - "ban": (Icons.user_ban, Icons.user_unban), - "kick": (Icons.sign_out, None), - "mute": (Icons.user_mute, Icons.user_unmute), - "note": (Icons.user_warn, None), - "superstar": (Icons.superstarify, Icons.unsuperstarify), - "warning": (Icons.user_warn, None), -} -RULES_URL = "https://pythondiscord.com/pages/rules" -APPEALABLE_INFRACTIONS = ("ban", "mute") - -# Type aliases -UserObject = t.Union[discord.Member, discord.User] -UserSnowflake = t.Union[UserObject, discord.Object] -Infraction = t.Dict[str, t.Union[str, int, bool]] - - -async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]: - """ - Create a new user in the database. - - Used when an infraction needs to be applied on a user absent in the guild. - """ - log.trace(f"Attempting to add user {user.id} to the database.") - - if not isinstance(user, (discord.Member, discord.User)): - log.debug("The user being added to the DB is not a Member or User object.") - - payload = { - 'discriminator': int(getattr(user, 'discriminator', 0)), - 'id': user.id, - 'in_guild': False, - 'name': getattr(user, 'name', 'Name unknown'), - 'roles': [] - } - - try: - response = await ctx.bot.api_client.post('bot/users', json=payload) - log.info(f"User {user.id} added to the DB.") - return response - except ResponseCodeError as e: - log.error(f"Failed to add user {user.id} to the DB. {e}") - await ctx.send(f":x: The attempt to add the user to the DB failed: status {e.status}") - - -async def post_infraction( - ctx: Context, - user: UserSnowflake, - infr_type: str, - reason: str, - expires_at: datetime = None, - hidden: bool = False, - active: bool = True -) -> t.Optional[dict]: - """Posts an infraction to the API.""" - log.trace(f"Posting {infr_type} infraction for {user} to the API.") - - payload = { - "actor": ctx.message.author.id, - "hidden": hidden, - "reason": reason, - "type": infr_type, - "user": user.id, - "active": active - } - if expires_at: - payload['expires_at'] = expires_at.isoformat() - - # Try to apply the infraction. If it fails because the user doesn't exist, try to add it. - for should_post_user in (True, False): - try: - response = await ctx.bot.api_client.post('bot/infractions', json=payload) - return response - except ResponseCodeError as e: - if e.status == 400 and 'user' in e.response_json: - # Only one attempt to add the user to the database, not two: - if not should_post_user or await post_user(ctx, user) is None: - return - else: - log.exception(f"Unexpected error while adding an infraction for {user}:") - await ctx.send(f":x: There was an error adding the infraction: status {e.status}.") - return - - -async def get_active_infraction( - ctx: Context, - user: UserSnowflake, - infr_type: str, - send_msg: bool = True -) -> t.Optional[dict]: - """ - Retrieves an active infraction of the given type for the user. - - If `send_msg` is True and the user has an active infraction matching the `infr_type` parameter, - then a message for the moderator will be sent to the context channel letting them know. - Otherwise, no message will be sent. - """ - log.trace(f"Checking if {user} has active infractions of type {infr_type}.") - - active_infractions = await ctx.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': infr_type, - 'user__id': str(user.id) - } - ) - if active_infractions: - # Checks to see if the moderator should be told there is an active infraction - if send_msg: - log.trace(f"{user} has active infractions of type {infr_type}.") - await ctx.send( - f":x: According to my records, this user already has a {infr_type} infraction. " - f"See infraction **#{active_infractions[0]['id']}**." - ) - return active_infractions[0] - else: - log.trace(f"{user} does not have active infractions of type {infr_type}.") - - -async def notify_infraction( - user: UserObject, - infr_type: str, - expires_at: t.Optional[str] = None, - reason: t.Optional[str] = None, - icon_url: str = Icons.token_removed -) -> bool: - """DM a user about their new infraction and return True if the DM is successful.""" - log.trace(f"Sending {user} a DM about their {infr_type} infraction.") - - text = textwrap.dedent(f""" - **Type:** {infr_type.capitalize()} - **Expires:** {expires_at or "N/A"} - **Reason:** {reason or "No reason provided."} - """) - - embed = discord.Embed( - description=textwrap.shorten(text, width=2048, placeholder="..."), - colour=Colours.soft_red - ) - - embed.set_author(name="Infraction information", icon_url=icon_url, url=RULES_URL) - embed.title = f"Please review our rules over at {RULES_URL}" - embed.url = RULES_URL - - if infr_type in APPEALABLE_INFRACTIONS: - embed.set_footer( - text="To appeal this infraction, send an e-mail to appeals@pythondiscord.com" - ) - - return await send_private_embed(user, embed) - - -async def notify_pardon( - user: UserObject, - title: str, - content: str, - icon_url: str = Icons.user_verified -) -> bool: - """DM a user about their pardoned infraction and return True if the DM is successful.""" - log.trace(f"Sending {user} a DM about their pardoned infraction.") - - embed = discord.Embed( - description=content, - colour=Colours.soft_green - ) - - embed.set_author(name=title, icon_url=icon_url) - - return await send_private_embed(user, embed) - - -async def send_private_embed(user: UserObject, embed: discord.Embed) -> bool: - """ - A helper method for sending an embed to a user's DMs. - - Returns a boolean indicator of DM success. - """ - try: - await user.send(embed=embed) - return True - except (discord.HTTPException, discord.Forbidden, discord.NotFound): - log.debug( - f"Infraction-related information could not be sent to user {user} ({user.id}). " - "The user either could not be retrieved or probably disabled their DMs." - ) - return False diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 0a63f57b8..c86f04b9d 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -830,3 +830,8 @@ class ModLog(Cog, name="ModLog"): thumbnail=member.avatar_url_as(static_format="png"), channel_id=Channels.voice_log ) + + +def setup(bot: Bot) -> None: + """Load the ModLog cog.""" + bot.add_cog(ModLog(bot)) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index f8a6592bc..4af87c724 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -163,3 +163,8 @@ class Silence(commands.Cog): def cog_check(self, ctx: Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" return with_role_check(ctx, *MODERATION_ROLES) + + +def setup(bot: Bot) -> None: + """Load the Silence cog.""" + bot.add_cog(Silence(bot)) diff --git a/bot/cogs/moderation/watchchannels/__init__.py b/bot/cogs/moderation/watchchannels/__init__.py index 69d118df6..e69de29bb 100644 --- a/bot/cogs/moderation/watchchannels/__init__.py +++ b/bot/cogs/moderation/watchchannels/__init__.py @@ -1,9 +0,0 @@ -from bot.bot import Bot -from .bigbrother import BigBrother -from .talentpool import TalentPool - - -def setup(bot: Bot) -> None: - """Load the BigBrother and TalentPool cogs.""" - bot.add_cog(BigBrother(bot)) - bot.add_cog(TalentPool(bot)) diff --git a/bot/cogs/moderation/watchchannels/_watchchannel.py b/bot/cogs/moderation/watchchannels/_watchchannel.py new file mode 100644 index 000000000..044077350 --- /dev/null +++ b/bot/cogs/moderation/watchchannels/_watchchannel.py @@ -0,0 +1,348 @@ +import asyncio +import logging +import re +import textwrap +from abc import abstractmethod +from collections import defaultdict, deque +from dataclasses import dataclass +from typing import Optional + +import dateutil.parser +import discord +from discord import Color, DMChannel, Embed, HTTPException, Message, errors +from discord.ext.commands import Cog, Context + +from bot.api import ResponseCodeError +from bot.bot import Bot +from bot.cogs.moderation import ModLog +from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons +from bot.pagination import LinePaginator +from bot.utils import CogABCMeta, messages +from bot.utils.time import time_since + +log = logging.getLogger(__name__) + +URL_RE = re.compile(r"(https?://[^\s]+)") + + +@dataclass +class MessageHistory: + """Represents a watch channel's message history.""" + + last_author: Optional[int] = None + last_channel: Optional[int] = None + message_count: int = 0 + + +class WatchChannel(metaclass=CogABCMeta): + """ABC with functionality for relaying users' messages to a certain channel.""" + + @abstractmethod + def __init__( + self, + bot: Bot, + destination: int, + webhook_id: int, + api_endpoint: str, + api_default_params: dict, + logger: logging.Logger + ) -> None: + self.bot = bot + + self.destination = destination # E.g., Channels.big_brother_logs + self.webhook_id = webhook_id # E.g., Webhooks.big_brother + self.api_endpoint = api_endpoint # E.g., 'bot/infractions' + self.api_default_params = api_default_params # E.g., {'active': 'true', 'type': 'watch'} + self.log = logger # Logger of the child cog for a correct name in the logs + + self._consume_task = None + self.watched_users = defaultdict(dict) + self.message_queue = defaultdict(lambda: defaultdict(deque)) + self.consumption_queue = {} + self.retries = 5 + self.retry_delay = 10 + self.channel = None + self.webhook = None + self.message_history = MessageHistory() + + self._start = self.bot.loop.create_task(self.start_watchchannel()) + + @property + def modlog(self) -> ModLog: + """Provides access to the ModLog cog for alert purposes.""" + return self.bot.get_cog("ModLog") + + @property + def consuming_messages(self) -> bool: + """Checks if a consumption task is currently running.""" + if self._consume_task is None: + return False + + if self._consume_task.done(): + exc = self._consume_task.exception() + if exc: + self.log.exception( + "The message queue consume task has failed with:", + exc_info=exc + ) + return False + + return True + + async def start_watchchannel(self) -> None: + """Starts the watch channel by getting the channel, webhook, and user cache ready.""" + await self.bot.wait_until_guild_available() + + try: + self.channel = await self.bot.fetch_channel(self.destination) + except HTTPException: + self.log.exception(f"Failed to retrieve the text channel with id `{self.destination}`") + + try: + self.webhook = await self.bot.fetch_webhook(self.webhook_id) + except discord.HTTPException: + self.log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") + + if self.channel is None or self.webhook is None: + self.log.error("Failed to start the watch channel; unloading the cog.") + + message = textwrap.dedent( + f""" + An error occurred while loading the text channel or webhook. + + TextChannel: {"**Failed to load**" if self.channel is None else "Loaded successfully"} + Webhook: {"**Failed to load**" if self.webhook is None else "Loaded successfully"} + + The Cog has been unloaded. + """ + ) + + await self.modlog.send_log_message( + title=f"Error: Failed to initialize the {self.__class__.__name__} watch channel", + text=message, + ping_everyone=True, + icon_url=Icons.token_removed, + colour=Color.red() + ) + + self.bot.remove_cog(self.__class__.__name__) + return + + if not await self.fetch_user_cache(): + await self.modlog.send_log_message( + title=f"Warning: Failed to retrieve user cache for the {self.__class__.__name__} watch channel", + text="Could not retrieve the list of watched users from the API and messages will not be relayed.", + ping_everyone=True, + icon_url=Icons.token_removed, + colour=Color.red() + ) + + async def fetch_user_cache(self) -> bool: + """ + Fetches watched users from the API and updates the watched user cache accordingly. + + This function returns `True` if the update succeeded. + """ + try: + data = await self.bot.api_client.get(self.api_endpoint, params=self.api_default_params) + except ResponseCodeError as err: + self.log.exception("Failed to fetch the watched users from the API", exc_info=err) + return False + + self.watched_users = defaultdict(dict) + + for entry in data: + user_id = entry.pop('user') + self.watched_users[user_id] = entry + + return True + + @Cog.listener() + async def on_message(self, msg: Message) -> None: + """Queues up messages sent by watched users.""" + if msg.author.id in self.watched_users: + if not self.consuming_messages: + self._consume_task = self.bot.loop.create_task(self.consume_messages()) + + self.log.trace(f"Received message: {msg.content} ({len(msg.attachments)} attachments)") + self.message_queue[msg.author.id][msg.channel.id].append(msg) + + async def consume_messages(self, delay_consumption: bool = True) -> None: + """Consumes the message queues to log watched users' messages.""" + if delay_consumption: + self.log.trace(f"Sleeping {BigBrotherConfig.log_delay} seconds before consuming message queue") + await asyncio.sleep(BigBrotherConfig.log_delay) + + self.log.trace("Started consuming the message queue") + + # If the previous consumption Task failed, first consume the existing comsumption_queue + if not self.consumption_queue: + self.consumption_queue = self.message_queue.copy() + self.message_queue.clear() + + for user_channel_queues in self.consumption_queue.values(): + for channel_queue in user_channel_queues.values(): + while channel_queue: + msg = channel_queue.popleft() + + self.log.trace(f"Consuming message {msg.id} ({len(msg.attachments)} attachments)") + await self.relay_message(msg) + + self.consumption_queue.clear() + + if self.message_queue: + self.log.trace("Channel queue not empty: Continuing consuming queues") + self._consume_task = self.bot.loop.create_task(self.consume_messages(delay_consumption=False)) + else: + self.log.trace("Done consuming messages.") + + async def webhook_send( + self, + content: Optional[str] = None, + username: Optional[str] = None, + avatar_url: Optional[str] = None, + embed: Optional[Embed] = None, + ) -> None: + """Sends a message to the webhook with the specified kwargs.""" + username = messages.sub_clyde(username) + try: + await self.webhook.send(content=content, username=username, avatar_url=avatar_url, embed=embed) + except discord.HTTPException as exc: + self.log.exception( + "Failed to send a message to the webhook", + exc_info=exc + ) + + async def relay_message(self, msg: Message) -> None: + """Relays the message to the relevant watch channel.""" + limit = BigBrotherConfig.header_message_limit + + if ( + msg.author.id != self.message_history.last_author + or msg.channel.id != self.message_history.last_channel + or self.message_history.message_count >= limit + ): + self.message_history = MessageHistory(last_author=msg.author.id, last_channel=msg.channel.id) + + await self.send_header(msg) + + cleaned_content = msg.clean_content + + if cleaned_content: + # Put all non-media URLs in a code block to prevent embeds + media_urls = {embed.url for embed in msg.embeds if embed.type in ("image", "video")} + for url in URL_RE.findall(cleaned_content): + if url not in media_urls: + cleaned_content = cleaned_content.replace(url, f"`{url}`") + await self.webhook_send( + cleaned_content, + username=msg.author.display_name, + avatar_url=msg.author.avatar_url + ) + + if msg.attachments: + try: + await messages.send_attachments(msg, self.webhook) + except (errors.Forbidden, errors.NotFound): + e = Embed( + description=":x: **This message contained an attachment, but it could not be retrieved**", + color=Color.red() + ) + await self.webhook_send( + embed=e, + username=msg.author.display_name, + avatar_url=msg.author.avatar_url + ) + except discord.HTTPException as exc: + self.log.exception( + "Failed to send an attachment to the webhook", + exc_info=exc + ) + + self.message_history.message_count += 1 + + async def send_header(self, msg: Message) -> None: + """Sends a header embed with information about the relayed messages to the watch channel.""" + user_id = msg.author.id + + guild = self.bot.get_guild(GuildConfig.id) + actor = guild.get_member(self.watched_users[user_id]['actor']) + actor = actor.display_name if actor else self.watched_users[user_id]['actor'] + + inserted_at = self.watched_users[user_id]['inserted_at'] + time_delta = self._get_time_delta(inserted_at) + + reason = self.watched_users[user_id]['reason'] + + if isinstance(msg.channel, DMChannel): + # If a watched user DMs the bot there won't be a channel name or jump URL + # This could technically include a GroupChannel but bot's can't be in those + message_jump = "via DM" + else: + message_jump = f"in [#{msg.channel.name}]({msg.jump_url})" + + footer = f"Added {time_delta} by {actor} | Reason: {reason}" + embed = Embed(description=f"{msg.author.mention} {message_jump}") + embed.set_footer(text=textwrap.shorten(footer, width=128, placeholder="...")) + + await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.avatar_url) + + async def list_watched_users( + self, ctx: Context, oldest_first: bool = False, update_cache: bool = True + ) -> None: + """ + Gives an overview of the watched user list for this channel. + + The optional kwarg `oldest_first` orders the list by oldest entry. + + The optional kwarg `update_cache` specifies whether the cache should + be refreshed by polling the API. + """ + if update_cache: + if not await self.fetch_user_cache(): + await ctx.send(f":x: Failed to update {self.__class__.__name__} user cache, serving from cache") + update_cache = False + + lines = [] + for user_id, user_data in self.watched_users.items(): + inserted_at = user_data['inserted_at'] + time_delta = self._get_time_delta(inserted_at) + lines.append(f"• <@{user_id}> (added {time_delta})") + + if oldest_first: + lines.reverse() + + lines = lines or ("There's nothing here yet.",) + + embed = Embed( + title=f"{self.__class__.__name__} watched users ({'updated' if update_cache else 'cached'})", + color=Color.blue() + ) + await LinePaginator.paginate(lines, ctx, embed, empty=False) + + @staticmethod + def _get_time_delta(time_string: str) -> str: + """Returns the time in human-readable time delta format.""" + date_time = dateutil.parser.isoparse(time_string).replace(tzinfo=None) + time_delta = time_since(date_time, precision="minutes", max_units=1) + + return time_delta + + def _remove_user(self, user_id: int) -> None: + """Removes a user from a watch channel.""" + self.watched_users.pop(user_id, None) + self.message_queue.pop(user_id, None) + self.consumption_queue.pop(user_id, None) + + def cog_unload(self) -> None: + """Takes care of unloading the cog and canceling the consumption task.""" + self.log.trace("Unloading the cog") + if self._consume_task and not self._consume_task.done(): + self._consume_task.cancel() + try: + self._consume_task.result() + except asyncio.CancelledError as e: + self.log.exception( + "The consume task was canceled. Messages may be lost.", + exc_info=e + ) diff --git a/bot/cogs/moderation/watchchannels/bigbrother.py b/bot/cogs/moderation/watchchannels/bigbrother.py index 0c72e88f7..7db34bcf2 100644 --- a/bot/cogs/moderation/watchchannels/bigbrother.py +++ b/bot/cogs/moderation/watchchannels/bigbrother.py @@ -5,11 +5,11 @@ from collections import ChainMap from discord.ext.commands import Cog, Context, group from bot.bot import Bot -from bot.cogs.moderation.infraction.utils import post_infraction +from bot.cogs.moderation.infraction._utils import post_infraction from bot.constants import Channels, MODERATION_ROLES, Webhooks from bot.converters import FetchedMember from bot.decorators import with_role -from .watchchannel import WatchChannel +from ._watchchannel import WatchChannel log = logging.getLogger(__name__) @@ -163,3 +163,8 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): message = ":x: The specified user is currently not being watched." await ctx.send(message) + + +def setup(bot: Bot) -> None: + """Load the BigBrother cog.""" + bot.add_cog(BigBrother(bot)) diff --git a/bot/cogs/moderation/watchchannels/talentpool.py b/bot/cogs/moderation/watchchannels/talentpool.py index 89256e92e..2972f56e1 100644 --- a/bot/cogs/moderation/watchchannels/talentpool.py +++ b/bot/cogs/moderation/watchchannels/talentpool.py @@ -12,7 +12,7 @@ from bot.converters import FetchedMember from bot.decorators import with_role from bot.pagination import LinePaginator from bot.utils import time -from .watchchannel import WatchChannel +from ._watchchannel import WatchChannel log = logging.getLogger(__name__) @@ -262,3 +262,8 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): ) return lines.strip() + + +def setup(bot: Bot) -> None: + """Load the TalentPool cog.""" + bot.add_cog(TalentPool(bot)) diff --git a/bot/cogs/moderation/watchchannels/watchchannel.py b/bot/cogs/moderation/watchchannels/watchchannel.py deleted file mode 100644 index 044077350..000000000 --- a/bot/cogs/moderation/watchchannels/watchchannel.py +++ /dev/null @@ -1,348 +0,0 @@ -import asyncio -import logging -import re -import textwrap -from abc import abstractmethod -from collections import defaultdict, deque -from dataclasses import dataclass -from typing import Optional - -import dateutil.parser -import discord -from discord import Color, DMChannel, Embed, HTTPException, Message, errors -from discord.ext.commands import Cog, Context - -from bot.api import ResponseCodeError -from bot.bot import Bot -from bot.cogs.moderation import ModLog -from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons -from bot.pagination import LinePaginator -from bot.utils import CogABCMeta, messages -from bot.utils.time import time_since - -log = logging.getLogger(__name__) - -URL_RE = re.compile(r"(https?://[^\s]+)") - - -@dataclass -class MessageHistory: - """Represents a watch channel's message history.""" - - last_author: Optional[int] = None - last_channel: Optional[int] = None - message_count: int = 0 - - -class WatchChannel(metaclass=CogABCMeta): - """ABC with functionality for relaying users' messages to a certain channel.""" - - @abstractmethod - def __init__( - self, - bot: Bot, - destination: int, - webhook_id: int, - api_endpoint: str, - api_default_params: dict, - logger: logging.Logger - ) -> None: - self.bot = bot - - self.destination = destination # E.g., Channels.big_brother_logs - self.webhook_id = webhook_id # E.g., Webhooks.big_brother - self.api_endpoint = api_endpoint # E.g., 'bot/infractions' - self.api_default_params = api_default_params # E.g., {'active': 'true', 'type': 'watch'} - self.log = logger # Logger of the child cog for a correct name in the logs - - self._consume_task = None - self.watched_users = defaultdict(dict) - self.message_queue = defaultdict(lambda: defaultdict(deque)) - self.consumption_queue = {} - self.retries = 5 - self.retry_delay = 10 - self.channel = None - self.webhook = None - self.message_history = MessageHistory() - - self._start = self.bot.loop.create_task(self.start_watchchannel()) - - @property - def modlog(self) -> ModLog: - """Provides access to the ModLog cog for alert purposes.""" - return self.bot.get_cog("ModLog") - - @property - def consuming_messages(self) -> bool: - """Checks if a consumption task is currently running.""" - if self._consume_task is None: - return False - - if self._consume_task.done(): - exc = self._consume_task.exception() - if exc: - self.log.exception( - "The message queue consume task has failed with:", - exc_info=exc - ) - return False - - return True - - async def start_watchchannel(self) -> None: - """Starts the watch channel by getting the channel, webhook, and user cache ready.""" - await self.bot.wait_until_guild_available() - - try: - self.channel = await self.bot.fetch_channel(self.destination) - except HTTPException: - self.log.exception(f"Failed to retrieve the text channel with id `{self.destination}`") - - try: - self.webhook = await self.bot.fetch_webhook(self.webhook_id) - except discord.HTTPException: - self.log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") - - if self.channel is None or self.webhook is None: - self.log.error("Failed to start the watch channel; unloading the cog.") - - message = textwrap.dedent( - f""" - An error occurred while loading the text channel or webhook. - - TextChannel: {"**Failed to load**" if self.channel is None else "Loaded successfully"} - Webhook: {"**Failed to load**" if self.webhook is None else "Loaded successfully"} - - The Cog has been unloaded. - """ - ) - - await self.modlog.send_log_message( - title=f"Error: Failed to initialize the {self.__class__.__name__} watch channel", - text=message, - ping_everyone=True, - icon_url=Icons.token_removed, - colour=Color.red() - ) - - self.bot.remove_cog(self.__class__.__name__) - return - - if not await self.fetch_user_cache(): - await self.modlog.send_log_message( - title=f"Warning: Failed to retrieve user cache for the {self.__class__.__name__} watch channel", - text="Could not retrieve the list of watched users from the API and messages will not be relayed.", - ping_everyone=True, - icon_url=Icons.token_removed, - colour=Color.red() - ) - - async def fetch_user_cache(self) -> bool: - """ - Fetches watched users from the API and updates the watched user cache accordingly. - - This function returns `True` if the update succeeded. - """ - try: - data = await self.bot.api_client.get(self.api_endpoint, params=self.api_default_params) - except ResponseCodeError as err: - self.log.exception("Failed to fetch the watched users from the API", exc_info=err) - return False - - self.watched_users = defaultdict(dict) - - for entry in data: - user_id = entry.pop('user') - self.watched_users[user_id] = entry - - return True - - @Cog.listener() - async def on_message(self, msg: Message) -> None: - """Queues up messages sent by watched users.""" - if msg.author.id in self.watched_users: - if not self.consuming_messages: - self._consume_task = self.bot.loop.create_task(self.consume_messages()) - - self.log.trace(f"Received message: {msg.content} ({len(msg.attachments)} attachments)") - self.message_queue[msg.author.id][msg.channel.id].append(msg) - - async def consume_messages(self, delay_consumption: bool = True) -> None: - """Consumes the message queues to log watched users' messages.""" - if delay_consumption: - self.log.trace(f"Sleeping {BigBrotherConfig.log_delay} seconds before consuming message queue") - await asyncio.sleep(BigBrotherConfig.log_delay) - - self.log.trace("Started consuming the message queue") - - # If the previous consumption Task failed, first consume the existing comsumption_queue - if not self.consumption_queue: - self.consumption_queue = self.message_queue.copy() - self.message_queue.clear() - - for user_channel_queues in self.consumption_queue.values(): - for channel_queue in user_channel_queues.values(): - while channel_queue: - msg = channel_queue.popleft() - - self.log.trace(f"Consuming message {msg.id} ({len(msg.attachments)} attachments)") - await self.relay_message(msg) - - self.consumption_queue.clear() - - if self.message_queue: - self.log.trace("Channel queue not empty: Continuing consuming queues") - self._consume_task = self.bot.loop.create_task(self.consume_messages(delay_consumption=False)) - else: - self.log.trace("Done consuming messages.") - - async def webhook_send( - self, - content: Optional[str] = None, - username: Optional[str] = None, - avatar_url: Optional[str] = None, - embed: Optional[Embed] = None, - ) -> None: - """Sends a message to the webhook with the specified kwargs.""" - username = messages.sub_clyde(username) - try: - await self.webhook.send(content=content, username=username, avatar_url=avatar_url, embed=embed) - except discord.HTTPException as exc: - self.log.exception( - "Failed to send a message to the webhook", - exc_info=exc - ) - - async def relay_message(self, msg: Message) -> None: - """Relays the message to the relevant watch channel.""" - limit = BigBrotherConfig.header_message_limit - - if ( - msg.author.id != self.message_history.last_author - or msg.channel.id != self.message_history.last_channel - or self.message_history.message_count >= limit - ): - self.message_history = MessageHistory(last_author=msg.author.id, last_channel=msg.channel.id) - - await self.send_header(msg) - - cleaned_content = msg.clean_content - - if cleaned_content: - # Put all non-media URLs in a code block to prevent embeds - media_urls = {embed.url for embed in msg.embeds if embed.type in ("image", "video")} - for url in URL_RE.findall(cleaned_content): - if url not in media_urls: - cleaned_content = cleaned_content.replace(url, f"`{url}`") - await self.webhook_send( - cleaned_content, - username=msg.author.display_name, - avatar_url=msg.author.avatar_url - ) - - if msg.attachments: - try: - await messages.send_attachments(msg, self.webhook) - except (errors.Forbidden, errors.NotFound): - e = Embed( - description=":x: **This message contained an attachment, but it could not be retrieved**", - color=Color.red() - ) - await self.webhook_send( - embed=e, - username=msg.author.display_name, - avatar_url=msg.author.avatar_url - ) - except discord.HTTPException as exc: - self.log.exception( - "Failed to send an attachment to the webhook", - exc_info=exc - ) - - self.message_history.message_count += 1 - - async def send_header(self, msg: Message) -> None: - """Sends a header embed with information about the relayed messages to the watch channel.""" - user_id = msg.author.id - - guild = self.bot.get_guild(GuildConfig.id) - actor = guild.get_member(self.watched_users[user_id]['actor']) - actor = actor.display_name if actor else self.watched_users[user_id]['actor'] - - inserted_at = self.watched_users[user_id]['inserted_at'] - time_delta = self._get_time_delta(inserted_at) - - reason = self.watched_users[user_id]['reason'] - - if isinstance(msg.channel, DMChannel): - # If a watched user DMs the bot there won't be a channel name or jump URL - # This could technically include a GroupChannel but bot's can't be in those - message_jump = "via DM" - else: - message_jump = f"in [#{msg.channel.name}]({msg.jump_url})" - - footer = f"Added {time_delta} by {actor} | Reason: {reason}" - embed = Embed(description=f"{msg.author.mention} {message_jump}") - embed.set_footer(text=textwrap.shorten(footer, width=128, placeholder="...")) - - await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.avatar_url) - - async def list_watched_users( - self, ctx: Context, oldest_first: bool = False, update_cache: bool = True - ) -> None: - """ - Gives an overview of the watched user list for this channel. - - The optional kwarg `oldest_first` orders the list by oldest entry. - - The optional kwarg `update_cache` specifies whether the cache should - be refreshed by polling the API. - """ - if update_cache: - if not await self.fetch_user_cache(): - await ctx.send(f":x: Failed to update {self.__class__.__name__} user cache, serving from cache") - update_cache = False - - lines = [] - for user_id, user_data in self.watched_users.items(): - inserted_at = user_data['inserted_at'] - time_delta = self._get_time_delta(inserted_at) - lines.append(f"• <@{user_id}> (added {time_delta})") - - if oldest_first: - lines.reverse() - - lines = lines or ("There's nothing here yet.",) - - embed = Embed( - title=f"{self.__class__.__name__} watched users ({'updated' if update_cache else 'cached'})", - color=Color.blue() - ) - await LinePaginator.paginate(lines, ctx, embed, empty=False) - - @staticmethod - def _get_time_delta(time_string: str) -> str: - """Returns the time in human-readable time delta format.""" - date_time = dateutil.parser.isoparse(time_string).replace(tzinfo=None) - time_delta = time_since(date_time, precision="minutes", max_units=1) - - return time_delta - - def _remove_user(self, user_id: int) -> None: - """Removes a user from a watch channel.""" - self.watched_users.pop(user_id, None) - self.message_queue.pop(user_id, None) - self.consumption_queue.pop(user_id, None) - - def cog_unload(self) -> None: - """Takes care of unloading the cog and canceling the consumption task.""" - self.log.trace("Unloading the cog") - if self._consume_task and not self._consume_task.done(): - self._consume_task.cancel() - try: - self._consume_task.result() - except asyncio.CancelledError as e: - self.log.exception( - "The consume task was canceled. Messages may be lost.", - exc_info=e - ) diff --git a/tests/bot/cogs/backend/sync/test_base.py b/tests/bot/cogs/backend/sync/test_base.py index 0d0a8299d..3009aacb6 100644 --- a/tests/bot/cogs/backend/sync/test_base.py +++ b/tests/bot/cogs/backend/sync/test_base.py @@ -6,7 +6,7 @@ import discord from bot import constants from bot.api import ResponseCodeError -from bot.cogs.backend.sync.syncers import Syncer, _Diff +from bot.cogs.backend.sync._syncers import Syncer, _Diff from tests import helpers diff --git a/tests/bot/cogs/backend/sync/test_cog.py b/tests/bot/cogs/backend/sync/test_cog.py index 199747051..e40552817 100644 --- a/tests/bot/cogs/backend/sync/test_cog.py +++ b/tests/bot/cogs/backend/sync/test_cog.py @@ -6,7 +6,8 @@ import discord from bot import constants from bot.api import ResponseCodeError from bot.cogs.backend import sync -from bot.cogs.backend.sync.syncers import Syncer +from bot.cogs.backend.sync._cog import Sync +from bot.cogs.backend.sync._syncers import Syncer from tests import helpers from tests.base import CommandTestCase @@ -29,19 +30,19 @@ class SyncCogTestCase(unittest.IsolatedAsyncioTestCase): self.bot = helpers.MockBot() self.role_syncer_patcher = mock.patch( - "bot.cogs.backend.sync.syncers.RoleSyncer", + "bot.cogs.backend.sync._syncers.RoleSyncer", autospec=Syncer, spec_set=True ) self.user_syncer_patcher = mock.patch( - "bot.cogs.backend.sync.syncers.UserSyncer", + "bot.cogs.backend.sync._syncers.UserSyncer", autospec=Syncer, spec_set=True ) self.RoleSyncer = self.role_syncer_patcher.start() self.UserSyncer = self.user_syncer_patcher.start() - self.cog = sync.Sync(self.bot) + self.cog = Sync(self.bot) def tearDown(self): self.role_syncer_patcher.stop() @@ -59,7 +60,7 @@ class SyncCogTestCase(unittest.IsolatedAsyncioTestCase): class SyncCogTests(SyncCogTestCase): """Tests for the Sync cog.""" - @mock.patch.object(sync.Sync, "sync_guild", new_callable=mock.MagicMock) + @mock.patch.object(Sync, "sync_guild", new_callable=mock.MagicMock) def test_sync_cog_init(self, sync_guild): """Should instantiate syncers and run a sync for the guild.""" # Reset because a Sync cog was already instantiated in setUp. @@ -70,7 +71,7 @@ class SyncCogTests(SyncCogTestCase): mock_sync_guild_coro = mock.MagicMock() sync_guild.return_value = mock_sync_guild_coro - sync.Sync(self.bot) + Sync(self.bot) self.RoleSyncer.assert_called_once_with(self.bot) self.UserSyncer.assert_called_once_with(self.bot) @@ -131,7 +132,7 @@ class SyncCogListenerTests(SyncCogTestCase): super().setUp() self.cog.patch_user = mock.AsyncMock(spec_set=self.cog.patch_user) - self.guild_id_patcher = mock.patch("bot.cogs.backend.sync.cog.constants.Guild.id", 5) + self.guild_id_patcher = mock.patch("bot.cogs.backend.sync._cog.constants.Guild.id", 5) self.guild_id = self.guild_id_patcher.start() self.guild = helpers.MockGuild(id=self.guild_id) diff --git a/tests/bot/cogs/backend/sync/test_roles.py b/tests/bot/cogs/backend/sync/test_roles.py index cc2e51c7f..99d682ede 100644 --- a/tests/bot/cogs/backend/sync/test_roles.py +++ b/tests/bot/cogs/backend/sync/test_roles.py @@ -3,7 +3,7 @@ from unittest import mock import discord -from bot.cogs.backend.sync.syncers import RoleSyncer, _Diff, _Role +from bot.cogs.backend.sync._syncers import RoleSyncer, _Diff, _Role from tests import helpers diff --git a/tests/bot/cogs/backend/sync/test_users.py b/tests/bot/cogs/backend/sync/test_users.py index 490ea9e06..51dcbe48a 100644 --- a/tests/bot/cogs/backend/sync/test_users.py +++ b/tests/bot/cogs/backend/sync/test_users.py @@ -1,7 +1,7 @@ import unittest from unittest import mock -from bot.cogs.backend.sync.syncers import UserSyncer, _Diff, _User +from bot.cogs.backend.sync._syncers import UserSyncer, _Diff, _User from tests import helpers diff --git a/tests/bot/cogs/moderation/infraction/test_infractions.py b/tests/bot/cogs/moderation/infraction/test_infractions.py index a79042557..2df61d431 100644 --- a/tests/bot/cogs/moderation/infraction/test_infractions.py +++ b/tests/bot/cogs/moderation/infraction/test_infractions.py @@ -17,8 +17,8 @@ class TruncationTests(unittest.IsolatedAsyncioTestCase): self.guild = MockGuild(id=4567) self.ctx = MockContext(bot=self.bot, author=self.user, guild=self.guild) - @patch("bot.cogs.moderation.infraction.utils.get_active_infraction") - @patch("bot.cogs.moderation.infraction.utils.post_infraction") + @patch("bot.cogs.moderation.infraction._utils.get_active_infraction") + @patch("bot.cogs.moderation.infraction._utils.post_infraction") async def test_apply_ban_reason_truncation(self, post_infraction_mock, get_active_mock): """Should truncate reason for `ctx.guild.ban`.""" get_active_mock.return_value = None @@ -39,7 +39,7 @@ class TruncationTests(unittest.IsolatedAsyncioTestCase): self.ctx, {"foo": "bar"}, self.target, self.ctx.guild.ban.return_value ) - @patch("bot.cogs.moderation.infraction.utils.post_infraction") + @patch("bot.cogs.moderation.infraction._utils.post_infraction") async def test_apply_kick_reason_truncation(self, post_infraction_mock): """Should truncate reason for `Member.kick`.""" post_infraction_mock.return_value = {"foo": "bar"} -- cgit v1.2.3 From aaee0f86e99f8dfdc454c52516fbdf7f0030168a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 12 Aug 2020 23:07:30 -0700 Subject: Fix ModLog imports Bunch of modules still rely on importing the cog directly from the moderation package. --- bot/cogs/filters/antispam.py | 2 +- bot/cogs/filters/filtering.py | 2 +- bot/cogs/filters/token_remover.py | 2 +- bot/cogs/moderation/defcon.py | 2 +- bot/cogs/moderation/verification.py | 2 +- bot/cogs/moderation/watchchannels/_watchchannel.py | 2 +- bot/cogs/utils/clean.py | 2 +- tests/bot/cogs/filters/test_token_remover.py | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/bot/cogs/filters/antispam.py b/bot/cogs/filters/antispam.py index 0bcca578d..d2dccea06 100644 --- a/bot/cogs/filters/antispam.py +++ b/bot/cogs/filters/antispam.py @@ -11,7 +11,7 @@ from discord.ext.commands import Cog from bot import rules from bot.bot import Bot -from bot.cogs.moderation import ModLog +from bot.cogs.moderation.modlog import ModLog from bot.constants import ( AntiSpam as AntiSpamConfig, Channels, Colours, DEBUG_MODE, Event, Filter, diff --git a/bot/cogs/filters/filtering.py b/bot/cogs/filters/filtering.py index 93cc1c655..556b466ef 100644 --- a/bot/cogs/filters/filtering.py +++ b/bot/cogs/filters/filtering.py @@ -12,7 +12,7 @@ from discord.ext.commands import Cog from discord.utils import escape_markdown from bot.bot import Bot -from bot.cogs.moderation import ModLog +from bot.cogs.moderation.modlog import ModLog from bot.constants import ( Channels, Colours, Filter, Icons, URLs diff --git a/bot/cogs/filters/token_remover.py b/bot/cogs/filters/token_remover.py index ef979f222..8eace07b6 100644 --- a/bot/cogs/filters/token_remover.py +++ b/bot/cogs/filters/token_remover.py @@ -9,7 +9,7 @@ from discord.ext.commands import Cog from bot import utils from bot.bot import Bot -from bot.cogs.moderation import ModLog +from bot.cogs.moderation.modlog import ModLog from bot.constants import Channels, Colours, Event, Icons log = logging.getLogger(__name__) diff --git a/bot/cogs/moderation/defcon.py b/bot/cogs/moderation/defcon.py index 4c0ad5914..e78435a7d 100644 --- a/bot/cogs/moderation/defcon.py +++ b/bot/cogs/moderation/defcon.py @@ -9,7 +9,7 @@ from discord import Colour, Embed, Member from discord.ext.commands import Cog, Context, group from bot.bot import Bot -from bot.cogs.moderation import ModLog +from bot.cogs.moderation.modlog import ModLog from bot.constants import Channels, Colours, Emojis, Event, Icons, Roles from bot.decorators import with_role diff --git a/bot/cogs/moderation/verification.py b/bot/cogs/moderation/verification.py index ae156cf70..ba95ab5e4 100644 --- a/bot/cogs/moderation/verification.py +++ b/bot/cogs/moderation/verification.py @@ -6,7 +6,7 @@ from discord.ext.commands import Cog, Context, command from bot import constants from bot.bot import Bot -from bot.cogs.moderation import ModLog +from bot.cogs.moderation.modlog import ModLog from bot.decorators import in_whitelist, without_role from bot.utils.checks import InWhitelistCheckFailure, without_role_check diff --git a/bot/cogs/moderation/watchchannels/_watchchannel.py b/bot/cogs/moderation/watchchannels/_watchchannel.py index 044077350..488ae704d 100644 --- a/bot/cogs/moderation/watchchannels/_watchchannel.py +++ b/bot/cogs/moderation/watchchannels/_watchchannel.py @@ -14,7 +14,7 @@ from discord.ext.commands import Cog, Context from bot.api import ResponseCodeError from bot.bot import Bot -from bot.cogs.moderation import ModLog +from bot.cogs.moderation.modlog import ModLog from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons from bot.pagination import LinePaginator from bot.utils import CogABCMeta, messages diff --git a/bot/cogs/utils/clean.py b/bot/cogs/utils/clean.py index f436e531a..c156ff02e 100644 --- a/bot/cogs/utils/clean.py +++ b/bot/cogs/utils/clean.py @@ -8,7 +8,7 @@ from discord.ext import commands from discord.ext.commands import Cog, Context, group from bot.bot import Bot -from bot.cogs.moderation import ModLog +from bot.cogs.moderation.modlog import ModLog from bot.constants import ( Channels, CleanMessages, Colours, Event, Icons, MODERATION_ROLES, NEGATIVE_REPLIES ) diff --git a/tests/bot/cogs/filters/test_token_remover.py b/tests/bot/cogs/filters/test_token_remover.py index 5c527ed94..55b284ef9 100644 --- a/tests/bot/cogs/filters/test_token_remover.py +++ b/tests/bot/cogs/filters/test_token_remover.py @@ -8,7 +8,7 @@ from discord import Colour, NotFound from bot import constants from bot.cogs.filters import token_remover from bot.cogs.filters.token_remover import Token, TokenRemover -from bot.cogs.moderation import ModLog +from bot.cogs.moderation.modlog import ModLog from tests.helpers import MockBot, MockMessage, autospec -- cgit v1.2.3 From bb548c2ca2d022b206ed0ca45acf85226ec70965 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 12 Aug 2020 23:11:33 -0700 Subject: Fix paths used to load extensions --- bot/__main__.py | 93 ++++++++++++++++++++++++++++++++++----------------------- 1 file changed, 55 insertions(+), 38 deletions(-) diff --git a/bot/__main__.py b/bot/__main__.py index f698b5662..4b0f6dfe4 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -33,48 +33,65 @@ bot = Bot( allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) ) -# Internal/debug -bot.load_extension("bot.cogs.config_verifier") -bot.load_extension("bot.cogs.error_handler") -bot.load_extension("bot.cogs.filtering") -bot.load_extension("bot.cogs.logging") -bot.load_extension("bot.cogs.security") - -# Commands, etc -bot.load_extension("bot.cogs.antimalware") -bot.load_extension("bot.cogs.antispam") -bot.load_extension("bot.cogs.bot") -bot.load_extension("bot.cogs.clean") -bot.load_extension("bot.cogs.doc") -bot.load_extension("bot.cogs.extensions") -bot.load_extension("bot.cogs.help") -bot.load_extension("bot.cogs.verification") - -# Feature cogs +# Backend +bot.load_extension("bot.cogs.backend.config_verifier") +bot.load_extension("bot.cogs.backend.error_handler") +bot.load_extension("bot.cogs.backend.logging") +bot.load_extension("bot.cogs.backend.sync") + +# Filters +bot.load_extension("bot.cogs.filters.antimalware") +bot.load_extension("bot.cogs.filters.antispam") +bot.load_extension("bot.cogs.filters.filter_lists") +bot.load_extension("bot.cogs.filters.filtering") +bot.load_extension("bot.cogs.filters.security") +bot.load_extension("bot.cogs.filters.token_remover") +bot.load_extension("bot.cogs.filters.webhook_remover") + +# Info +bot.load_extension("bot.cogs.info.doc") +bot.load_extension("bot.cogs.info.help") +bot.load_extension("bot.cogs.info.information") +bot.load_extension("bot.cogs.info.python_news") +bot.load_extension("bot.cogs.info.reddit") +bot.load_extension("bot.cogs.info.site") +bot.load_extension("bot.cogs.info.source") +bot.load_extension("bot.cogs.info.stats") +bot.load_extension("bot.cogs.info.tags") +bot.load_extension("bot.cogs.info.wolfram") + +# Moderation +bot.load_extension("bot.cogs.moderation.defcon") +bot.load_extension("bot.cogs.moderation.incidents") +bot.load_extension("bot.cogs.moderation.modlog") +bot.load_extension("bot.cogs.moderation.silence") +bot.load_extension("bot.cogs.moderation.slowmode") +bot.load_extension("bot.cogs.moderation.verification") + +# Moderation - Infraction +bot.load_extension("bot.cogs.moderation.infraction.infractions") +bot.load_extension("bot.cogs.moderation.infraction.management") +bot.load_extension("bot.cogs.moderation.infraction.superstarify") + +# Moderation - Watchchannels +bot.load_extension("bot.cogs.moderation.watchchannels.bigbrother") +bot.load_extension("bot.cogs.moderation.watchchannels.talentpool") + +# Utils +bot.load_extension("bot.cogs.utils.bot") +bot.load_extension("bot.cogs.utils.clean") +bot.load_extension("bot.cogs.utils.eval") +bot.load_extension("bot.cogs.utils.extensions") +bot.load_extension("bot.cogs.utils.jams") +bot.load_extension("bot.cogs.utils.reminders") +bot.load_extension("bot.cogs.utils.snekbox") +bot.load_extension("bot.cogs.utils.utils") + +# Misc bot.load_extension("bot.cogs.alias") -bot.load_extension("bot.cogs.defcon") bot.load_extension("bot.cogs.dm_relay") bot.load_extension("bot.cogs.duck_pond") -bot.load_extension("bot.cogs.eval") -bot.load_extension("bot.cogs.filter_lists") -bot.load_extension("bot.cogs.information") -bot.load_extension("bot.cogs.jams") -bot.load_extension("bot.cogs.moderation") bot.load_extension("bot.cogs.off_topic_names") -bot.load_extension("bot.cogs.python_news") -bot.load_extension("bot.cogs.reddit") -bot.load_extension("bot.cogs.reminders") -bot.load_extension("bot.cogs.site") -bot.load_extension("bot.cogs.snekbox") -bot.load_extension("bot.cogs.source") -bot.load_extension("bot.cogs.stats") -bot.load_extension("bot.cogs.sync") -bot.load_extension("bot.cogs.tags") -bot.load_extension("bot.cogs.token_remover") -bot.load_extension("bot.cogs.utils") -bot.load_extension("bot.cogs.watchchannels") -bot.load_extension("bot.cogs.webhook_remover") -bot.load_extension("bot.cogs.wolfram") if constants.HelpChannels.enable: bot.load_extension("bot.cogs.help_channels") -- cgit v1.2.3 From 34064231d4c2afffc6b6b40d1e2f59f15d897c04 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 12 Aug 2020 23:22:18 -0700 Subject: Extensions: adjust discovery to work with dir structure Discover extensions recursively and ignore any modules/packages whose names start with an underscore. --- bot/cogs/utils/extensions.py | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/bot/cogs/utils/extensions.py b/bot/cogs/utils/extensions.py index 365f198ff..d01825fdd 100644 --- a/bot/cogs/utils/extensions.py +++ b/bot/cogs/utils/extensions.py @@ -1,13 +1,16 @@ import functools +import importlib +import inspect import logging +import pkgutil import typing as t from enum import Enum -from pkgutil import iter_modules from discord import Colour, Embed from discord.ext import commands from discord.ext.commands import Context, group +from bot import cogs from bot.bot import Bot from bot.constants import Emojis, MODERATION_ROLES, Roles, URLs from bot.pagination import LinePaginator @@ -15,12 +18,29 @@ from bot.utils.checks import with_role_check log = logging.getLogger(__name__) -UNLOAD_BLACKLIST = {"bot.cogs.extensions", "bot.cogs.modlog"} -EXTENSIONS = frozenset( - ext.name - for ext in iter_modules(("bot/cogs",), "bot.cogs.") - if ext.name[-1] != "_" -) + +def walk_extensions() -> t.Iterator[str]: + """Yield extension names from the bot.cogs subpackage.""" + + def on_error(name: str) -> t.NoReturn: + raise ImportError(name=name) # pragma: no cover + + for module in pkgutil.walk_packages(cogs.__path__, f"{cogs.__name__}.", onerror=on_error): + if module.name.rsplit(".", maxsplit=1)[-1].startswith("_"): + # Ignore module/package names starting with an underscore. + continue + + if module.ispkg: + imported = importlib.import_module(module.name) + if not inspect.isfunction(getattr(imported, "setup", None)): + # If it lacks a setup function, it's not an extension. + continue + + yield module.name + + +UNLOAD_BLACKLIST = {f"{cogs.__name__}.utils.extensions", f"{cogs.__name__}.moderation.modlog"} +EXTENSIONS = frozenset(walk_extensions()) class Action(Enum): @@ -48,7 +68,7 @@ class Extension(commands.Converter): argument = argument.lower() if "." not in argument: - argument = f"bot.cogs.{argument}" + argument = f"{cogs.__name__}.{argument}" if argument in EXTENSIONS: return argument -- cgit v1.2.3 From 74cd105239cf679fdf18a4c90f7cb1c9cfebfb19 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 13 Aug 2020 17:04:32 -0700 Subject: Extensions: group by category in list command --- bot/cogs/utils/extensions.py | 38 +++++++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/bot/cogs/utils/extensions.py b/bot/cogs/utils/extensions.py index d01825fdd..c713cb519 100644 --- a/bot/cogs/utils/extensions.py +++ b/bot/cogs/utils/extensions.py @@ -41,6 +41,7 @@ def walk_extensions() -> t.Iterator[str]: UNLOAD_BLACKLIST = {f"{cogs.__name__}.utils.extensions", f"{cogs.__name__}.moderation.modlog"} EXTENSIONS = frozenset(walk_extensions()) +COG_PATH_LEN = len(cogs.__name__.split(".")) class Action(Enum): @@ -159,27 +160,46 @@ class Extensions(commands.Cog): Grey indicates that the extension is unloaded. Green indicates that the extension is currently loaded. """ - embed = Embed() - lines = [] - - embed.colour = Colour.blurple() + embed = Embed(colour=Colour.blurple()) embed.set_author( name="Extensions List", url=URLs.github_bot_repo, icon_url=URLs.bot_avatar ) - for ext in sorted(list(EXTENSIONS)): + lines = [] + categories = self.group_extension_statuses() + for category, extensions in categories.items(): + # Treat each category as a single line by concatenating everything. + # This ensures the paginator will not cut off a page in the middle of a category. + category = category.replace("_", " ").capitalize() + extensions = "\n".join(sorted(extensions)) + lines.append(f"**{category}**\n{extensions}\n") + + lines.sort() # Sort by category name. + + log.debug(f"{ctx.author} requested a list of all cogs. Returning a paginated list.") + await LinePaginator.paginate(lines, ctx, embed, scale_to_size=700, empty=False) + + def group_extension_statuses(self) -> t.Mapping[str, str]: + """Return a mapping of extension names and statuses to their categories.""" + categories = {} + + for ext in EXTENSIONS: if ext in self.bot.extensions: status = Emojis.status_online else: status = Emojis.status_offline - ext = ext.rsplit(".", 1)[1] - lines.append(f"{status} {ext}") + path = ext.split(".") + if len(path) > COG_PATH_LEN + 1: + extensions = categories.setdefault(path[COG_PATH_LEN], []) + else: + extensions = categories.setdefault("Uncategorised", []) - log.debug(f"{ctx.author} requested a list of all cogs. Returning a paginated list.") - await LinePaginator.paginate(lines, ctx, embed, max_size=300, empty=False) + extensions.append(f"{status} {path[-1]}") + + return categories def batch_manage(self, action: Action, *extensions: str) -> str: """ -- cgit v1.2.3 From e81b8d678b24e1504d88901e824e9c52f2d6afcf Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 13 Aug 2020 17:14:11 -0700 Subject: Extensions: support nested groupings in list command --- bot/cogs/utils/extensions.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/bot/cogs/utils/extensions.py b/bot/cogs/utils/extensions.py index c713cb519..90558c52a 100644 --- a/bot/cogs/utils/extensions.py +++ b/bot/cogs/utils/extensions.py @@ -169,15 +169,13 @@ class Extensions(commands.Cog): lines = [] categories = self.group_extension_statuses() - for category, extensions in categories.items(): + for category, extensions in sorted(categories.items()): # Treat each category as a single line by concatenating everything. # This ensures the paginator will not cut off a page in the middle of a category. - category = category.replace("_", " ").capitalize() + category = category.replace("_", " ").title() extensions = "\n".join(sorted(extensions)) lines.append(f"**{category}**\n{extensions}\n") - lines.sort() # Sort by category name. - log.debug(f"{ctx.author} requested a list of all cogs. Returning a paginated list.") await LinePaginator.paginate(lines, ctx, embed, scale_to_size=700, empty=False) @@ -193,9 +191,10 @@ class Extensions(commands.Cog): path = ext.split(".") if len(path) > COG_PATH_LEN + 1: - extensions = categories.setdefault(path[COG_PATH_LEN], []) + category = " - ".join(path[COG_PATH_LEN:-1]) + extensions = categories.setdefault(category, []) else: - extensions = categories.setdefault("Uncategorised", []) + extensions = categories.setdefault("uncategorised", []) extensions.append(f"{status} {path[-1]}") -- cgit v1.2.3 From 1e4aabec382529aed878b7d548f13a2705da42dc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 13 Aug 2020 18:10:03 -0700 Subject: Extensions: refactor category grouping code --- bot/cogs/utils/extensions.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/cogs/utils/extensions.py b/bot/cogs/utils/extensions.py index 90558c52a..52b5adefc 100644 --- a/bot/cogs/utils/extensions.py +++ b/bot/cogs/utils/extensions.py @@ -192,11 +192,10 @@ class Extensions(commands.Cog): path = ext.split(".") if len(path) > COG_PATH_LEN + 1: category = " - ".join(path[COG_PATH_LEN:-1]) - extensions = categories.setdefault(category, []) else: - extensions = categories.setdefault("uncategorised", []) + category = "uncategorised" - extensions.append(f"{status} {path[-1]}") + categories.setdefault(category, []).append(f"{status} {path[-1]}") return categories -- cgit v1.2.3 From 394aca8451c930b3807ec6b944deee7546701858 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 13 Aug 2020 19:00:11 -0700 Subject: Extensions: support unqualified extension names It's convenient for users to type less to specify the exception they want. Only require a qualified name if an unqualified name is ambiguous (i.e. two modules in different subpackages have identical names). --- bot/cogs/utils/extensions.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/bot/cogs/utils/extensions.py b/bot/cogs/utils/extensions.py index 52b5adefc..2cde07035 100644 --- a/bot/cogs/utils/extensions.py +++ b/bot/cogs/utils/extensions.py @@ -68,11 +68,26 @@ class Extension(commands.Converter): argument = argument.lower() - if "." not in argument: - argument = f"{cogs.__name__}.{argument}" - if argument in EXTENSIONS: return argument + elif (qualified_arg := f"{cogs.__name__}.{argument}") in EXTENSIONS: + return qualified_arg + + matches = [] + for ext in EXTENSIONS: + name = ext.rsplit(".", maxsplit=1)[-1] + if argument == name: + matches.append(ext) + + if len(matches) > 1: + matches.sort() + names = "\n".join(matches) + raise commands.BadArgument( + f":x: `{argument}` is an ambiguous extension name. " + f"Please use one of the following fully-qualified names.```\n{names}```" + ) + elif matches: + return matches[0] else: raise commands.BadArgument(f":x: Could not find the extension `{argument}`.") -- cgit v1.2.3 From 7e184c3b1921c6dc50659c158f427bd43b6a0791 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 13 Aug 2020 19:19:36 -0700 Subject: Defer imports in extensions using __init__.py Since `pkgutil.walk_packages` imports packages it comes across, it's best to avoid potential side effects from imports. --- bot/cogs/backend/sync/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/backend/sync/__init__.py b/bot/cogs/backend/sync/__init__.py index fb640a1cf..2541beaa8 100644 --- a/bot/cogs/backend/sync/__init__.py +++ b/bot/cogs/backend/sync/__init__.py @@ -1,7 +1,7 @@ from bot.bot import Bot -from ._cog import Sync def setup(bot: Bot) -> None: """Load the Sync cog.""" + from ._cog import Sync bot.add_cog(Sync(bot)) -- cgit v1.2.3 From 1c2b384915f4a7ba070c95c86126746bae2f7279 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 14 Aug 2020 09:59:56 -0700 Subject: Rename "cogs" directory to "exts" The directory contains modules, which are extensions. It only indirectly contains cogs through the extensions. Therefore, a technically more accurate name is "extensions", or "exts" when abbreviated. Furthermore, "exts" is consistent with SeasonalBot. --- bot/__main__.py | 90 +- bot/cogs/__init__.py | 0 bot/cogs/alias.py | 153 ---- bot/cogs/backend/__init__.py | 0 bot/cogs/backend/config_verifier.py | 40 - bot/cogs/backend/error_handler.py | 287 ------- bot/cogs/backend/logging.py | 42 - bot/cogs/backend/sync/__init__.py | 7 - bot/cogs/backend/sync/_cog.py | 180 ---- bot/cogs/backend/sync/_syncers.py | 347 -------- bot/cogs/dm_relay.py | 124 --- bot/cogs/duck_pond.py | 166 ---- bot/cogs/filters/__init__.py | 0 bot/cogs/filters/antimalware.py | 98 --- bot/cogs/filters/antispam.py | 288 ------- bot/cogs/filters/filter_lists.py | 273 ------ bot/cogs/filters/filtering.py | 575 ------------- bot/cogs/filters/security.py | 31 - bot/cogs/filters/token_remover.py | 182 ---- bot/cogs/filters/webhook_remover.py | 84 -- bot/cogs/help_channels.py | 944 --------------------- bot/cogs/info/__init__.py | 0 bot/cogs/info/doc.py | 511 ----------- bot/cogs/info/help.py | 375 -------- bot/cogs/info/information.py | 422 --------- bot/cogs/info/python_news.py | 232 ----- bot/cogs/info/reddit.py | 304 ------- bot/cogs/info/site.py | 146 ---- bot/cogs/info/source.py | 141 --- bot/cogs/info/stats.py | 129 --- bot/cogs/info/tags.py | 277 ------ bot/cogs/info/wolfram.py | 280 ------ bot/cogs/moderation/__init__.py | 0 bot/cogs/moderation/defcon.py | 258 ------ bot/cogs/moderation/incidents.py | 412 --------- bot/cogs/moderation/infraction/__init__.py | 0 bot/cogs/moderation/infraction/_scheduler.py | 463 ---------- bot/cogs/moderation/infraction/_utils.py | 201 ----- bot/cogs/moderation/infraction/infractions.py | 375 -------- bot/cogs/moderation/infraction/management.py | 310 ------- bot/cogs/moderation/infraction/superstarify.py | 244 ------ bot/cogs/moderation/modlog.py | 837 ------------------ bot/cogs/moderation/silence.py | 170 ---- bot/cogs/moderation/slowmode.py | 97 --- bot/cogs/moderation/verification.py | 191 ----- bot/cogs/moderation/watchchannels/__init__.py | 0 bot/cogs/moderation/watchchannels/_watchchannel.py | 348 -------- bot/cogs/moderation/watchchannels/bigbrother.py | 170 ---- bot/cogs/moderation/watchchannels/talentpool.py | 269 ------ bot/cogs/off_topic_names.py | 162 ---- bot/cogs/utils/__init__.py | 0 bot/cogs/utils/bot.py | 385 --------- bot/cogs/utils/clean.py | 272 ------ bot/cogs/utils/eval.py | 202 ----- bot/cogs/utils/extensions.py | 289 ------- bot/cogs/utils/jams.py | 150 ---- bot/cogs/utils/reminders.py | 427 ---------- bot/cogs/utils/snekbox.py | 349 -------- bot/cogs/utils/utils.py | 265 ------ bot/exts/__init__.py | 0 bot/exts/alias.py | 153 ++++ bot/exts/backend/__init__.py | 0 bot/exts/backend/config_verifier.py | 40 + bot/exts/backend/error_handler.py | 287 +++++++ bot/exts/backend/logging.py | 42 + bot/exts/backend/sync/__init__.py | 7 + bot/exts/backend/sync/_cog.py | 180 ++++ bot/exts/backend/sync/_syncers.py | 347 ++++++++ bot/exts/dm_relay.py | 124 +++ bot/exts/duck_pond.py | 166 ++++ bot/exts/filters/__init__.py | 0 bot/exts/filters/antimalware.py | 98 +++ bot/exts/filters/antispam.py | 288 +++++++ bot/exts/filters/filter_lists.py | 273 ++++++ bot/exts/filters/filtering.py | 575 +++++++++++++ bot/exts/filters/security.py | 31 + bot/exts/filters/token_remover.py | 182 ++++ bot/exts/filters/webhook_remover.py | 84 ++ bot/exts/help_channels.py | 944 +++++++++++++++++++++ bot/exts/info/__init__.py | 0 bot/exts/info/doc.py | 511 +++++++++++ bot/exts/info/help.py | 375 ++++++++ bot/exts/info/information.py | 422 +++++++++ bot/exts/info/python_news.py | 232 +++++ bot/exts/info/reddit.py | 304 +++++++ bot/exts/info/site.py | 146 ++++ bot/exts/info/source.py | 141 +++ bot/exts/info/stats.py | 129 +++ bot/exts/info/tags.py | 277 ++++++ bot/exts/info/wolfram.py | 280 ++++++ bot/exts/moderation/__init__.py | 0 bot/exts/moderation/defcon.py | 258 ++++++ bot/exts/moderation/incidents.py | 412 +++++++++ bot/exts/moderation/infraction/__init__.py | 0 bot/exts/moderation/infraction/_scheduler.py | 463 ++++++++++ bot/exts/moderation/infraction/_utils.py | 201 +++++ bot/exts/moderation/infraction/infractions.py | 375 ++++++++ bot/exts/moderation/infraction/management.py | 310 +++++++ bot/exts/moderation/infraction/superstarify.py | 244 ++++++ bot/exts/moderation/modlog.py | 837 ++++++++++++++++++ bot/exts/moderation/silence.py | 170 ++++ bot/exts/moderation/slowmode.py | 97 +++ bot/exts/moderation/verification.py | 191 +++++ bot/exts/moderation/watchchannels/__init__.py | 0 bot/exts/moderation/watchchannels/_watchchannel.py | 348 ++++++++ bot/exts/moderation/watchchannels/bigbrother.py | 170 ++++ bot/exts/moderation/watchchannels/talentpool.py | 269 ++++++ bot/exts/off_topic_names.py | 162 ++++ bot/exts/utils/__init__.py | 0 bot/exts/utils/bot.py | 385 +++++++++ bot/exts/utils/clean.py | 272 ++++++ bot/exts/utils/eval.py | 202 +++++ bot/exts/utils/extensions.py | 289 +++++++ bot/exts/utils/jams.py | 150 ++++ bot/exts/utils/reminders.py | 427 ++++++++++ bot/exts/utils/snekbox.py | 349 ++++++++ bot/exts/utils/utils.py | 265 ++++++ tests/bot/cogs/__init__.py | 0 tests/bot/cogs/backend/__init__.py | 0 tests/bot/cogs/backend/sync/__init__.py | 0 tests/bot/cogs/backend/sync/test_base.py | 404 --------- tests/bot/cogs/backend/sync/test_cog.py | 416 --------- tests/bot/cogs/backend/sync/test_roles.py | 157 ---- tests/bot/cogs/backend/sync/test_users.py | 158 ---- tests/bot/cogs/backend/test_logging.py | 32 - tests/bot/cogs/filters/__init__.py | 0 tests/bot/cogs/filters/test_antimalware.py | 165 ---- tests/bot/cogs/filters/test_antispam.py | 35 - tests/bot/cogs/filters/test_security.py | 54 -- tests/bot/cogs/filters/test_token_remover.py | 310 ------- tests/bot/cogs/info/__init__.py | 0 tests/bot/cogs/info/test_information.py | 584 ------------- tests/bot/cogs/moderation/__init__.py | 0 tests/bot/cogs/moderation/infraction/__init__.py | 0 .../cogs/moderation/infraction/test_infractions.py | 55 -- tests/bot/cogs/moderation/test_incidents.py | 770 ----------------- tests/bot/cogs/moderation/test_modlog.py | 29 - tests/bot/cogs/moderation/test_silence.py | 261 ------ tests/bot/cogs/moderation/test_slowmode.py | 111 --- tests/bot/cogs/test_cogs.py | 80 -- tests/bot/cogs/test_duck_pond.py | 548 ------------ tests/bot/cogs/utils/__init__.py | 0 tests/bot/cogs/utils/test_jams.py | 173 ---- tests/bot/cogs/utils/test_snekbox.py | 409 --------- tests/bot/exts/__init__.py | 0 tests/bot/exts/backend/__init__.py | 0 tests/bot/exts/backend/sync/__init__.py | 0 tests/bot/exts/backend/sync/test_base.py | 404 +++++++++ tests/bot/exts/backend/sync/test_cog.py | 416 +++++++++ tests/bot/exts/backend/sync/test_roles.py | 157 ++++ tests/bot/exts/backend/sync/test_users.py | 158 ++++ tests/bot/exts/backend/test_logging.py | 32 + tests/bot/exts/filters/__init__.py | 0 tests/bot/exts/filters/test_antimalware.py | 165 ++++ tests/bot/exts/filters/test_antispam.py | 35 + tests/bot/exts/filters/test_security.py | 54 ++ tests/bot/exts/filters/test_token_remover.py | 310 +++++++ tests/bot/exts/info/__init__.py | 0 tests/bot/exts/info/test_information.py | 584 +++++++++++++ tests/bot/exts/moderation/__init__.py | 0 tests/bot/exts/moderation/infraction/__init__.py | 0 .../exts/moderation/infraction/test_infractions.py | 55 ++ tests/bot/exts/moderation/test_incidents.py | 770 +++++++++++++++++ tests/bot/exts/moderation/test_modlog.py | 29 + tests/bot/exts/moderation/test_silence.py | 261 ++++++ tests/bot/exts/moderation/test_slowmode.py | 111 +++ tests/bot/exts/test_cogs.py | 81 ++ tests/bot/exts/test_duck_pond.py | 548 ++++++++++++ tests/bot/exts/utils/__init__.py | 0 tests/bot/exts/utils/test_jams.py | 173 ++++ tests/bot/exts/utils/test_snekbox.py | 409 +++++++++ 171 files changed, 18281 insertions(+), 18280 deletions(-) delete mode 100644 bot/cogs/__init__.py delete mode 100644 bot/cogs/alias.py delete mode 100644 bot/cogs/backend/__init__.py delete mode 100644 bot/cogs/backend/config_verifier.py delete mode 100644 bot/cogs/backend/error_handler.py delete mode 100644 bot/cogs/backend/logging.py delete mode 100644 bot/cogs/backend/sync/__init__.py delete mode 100644 bot/cogs/backend/sync/_cog.py delete mode 100644 bot/cogs/backend/sync/_syncers.py delete mode 100644 bot/cogs/dm_relay.py delete mode 100644 bot/cogs/duck_pond.py delete mode 100644 bot/cogs/filters/__init__.py delete mode 100644 bot/cogs/filters/antimalware.py delete mode 100644 bot/cogs/filters/antispam.py delete mode 100644 bot/cogs/filters/filter_lists.py delete mode 100644 bot/cogs/filters/filtering.py delete mode 100644 bot/cogs/filters/security.py delete mode 100644 bot/cogs/filters/token_remover.py delete mode 100644 bot/cogs/filters/webhook_remover.py delete mode 100644 bot/cogs/help_channels.py delete mode 100644 bot/cogs/info/__init__.py delete mode 100644 bot/cogs/info/doc.py delete mode 100644 bot/cogs/info/help.py delete mode 100644 bot/cogs/info/information.py delete mode 100644 bot/cogs/info/python_news.py delete mode 100644 bot/cogs/info/reddit.py delete mode 100644 bot/cogs/info/site.py delete mode 100644 bot/cogs/info/source.py delete mode 100644 bot/cogs/info/stats.py delete mode 100644 bot/cogs/info/tags.py delete mode 100644 bot/cogs/info/wolfram.py delete mode 100644 bot/cogs/moderation/__init__.py delete mode 100644 bot/cogs/moderation/defcon.py delete mode 100644 bot/cogs/moderation/incidents.py delete mode 100644 bot/cogs/moderation/infraction/__init__.py delete mode 100644 bot/cogs/moderation/infraction/_scheduler.py delete mode 100644 bot/cogs/moderation/infraction/_utils.py delete mode 100644 bot/cogs/moderation/infraction/infractions.py delete mode 100644 bot/cogs/moderation/infraction/management.py delete mode 100644 bot/cogs/moderation/infraction/superstarify.py delete mode 100644 bot/cogs/moderation/modlog.py delete mode 100644 bot/cogs/moderation/silence.py delete mode 100644 bot/cogs/moderation/slowmode.py delete mode 100644 bot/cogs/moderation/verification.py delete mode 100644 bot/cogs/moderation/watchchannels/__init__.py delete mode 100644 bot/cogs/moderation/watchchannels/_watchchannel.py delete mode 100644 bot/cogs/moderation/watchchannels/bigbrother.py delete mode 100644 bot/cogs/moderation/watchchannels/talentpool.py delete mode 100644 bot/cogs/off_topic_names.py delete mode 100644 bot/cogs/utils/__init__.py delete mode 100644 bot/cogs/utils/bot.py delete mode 100644 bot/cogs/utils/clean.py delete mode 100644 bot/cogs/utils/eval.py delete mode 100644 bot/cogs/utils/extensions.py delete mode 100644 bot/cogs/utils/jams.py delete mode 100644 bot/cogs/utils/reminders.py delete mode 100644 bot/cogs/utils/snekbox.py delete mode 100644 bot/cogs/utils/utils.py create mode 100644 bot/exts/__init__.py create mode 100644 bot/exts/alias.py create mode 100644 bot/exts/backend/__init__.py create mode 100644 bot/exts/backend/config_verifier.py create mode 100644 bot/exts/backend/error_handler.py create mode 100644 bot/exts/backend/logging.py create mode 100644 bot/exts/backend/sync/__init__.py create mode 100644 bot/exts/backend/sync/_cog.py create mode 100644 bot/exts/backend/sync/_syncers.py create mode 100644 bot/exts/dm_relay.py create mode 100644 bot/exts/duck_pond.py create mode 100644 bot/exts/filters/__init__.py create mode 100644 bot/exts/filters/antimalware.py create mode 100644 bot/exts/filters/antispam.py create mode 100644 bot/exts/filters/filter_lists.py create mode 100644 bot/exts/filters/filtering.py create mode 100644 bot/exts/filters/security.py create mode 100644 bot/exts/filters/token_remover.py create mode 100644 bot/exts/filters/webhook_remover.py create mode 100644 bot/exts/help_channels.py create mode 100644 bot/exts/info/__init__.py create mode 100644 bot/exts/info/doc.py create mode 100644 bot/exts/info/help.py create mode 100644 bot/exts/info/information.py create mode 100644 bot/exts/info/python_news.py create mode 100644 bot/exts/info/reddit.py create mode 100644 bot/exts/info/site.py create mode 100644 bot/exts/info/source.py create mode 100644 bot/exts/info/stats.py create mode 100644 bot/exts/info/tags.py create mode 100644 bot/exts/info/wolfram.py create mode 100644 bot/exts/moderation/__init__.py create mode 100644 bot/exts/moderation/defcon.py create mode 100644 bot/exts/moderation/incidents.py create mode 100644 bot/exts/moderation/infraction/__init__.py create mode 100644 bot/exts/moderation/infraction/_scheduler.py create mode 100644 bot/exts/moderation/infraction/_utils.py create mode 100644 bot/exts/moderation/infraction/infractions.py create mode 100644 bot/exts/moderation/infraction/management.py create mode 100644 bot/exts/moderation/infraction/superstarify.py create mode 100644 bot/exts/moderation/modlog.py create mode 100644 bot/exts/moderation/silence.py create mode 100644 bot/exts/moderation/slowmode.py create mode 100644 bot/exts/moderation/verification.py create mode 100644 bot/exts/moderation/watchchannels/__init__.py create mode 100644 bot/exts/moderation/watchchannels/_watchchannel.py create mode 100644 bot/exts/moderation/watchchannels/bigbrother.py create mode 100644 bot/exts/moderation/watchchannels/talentpool.py create mode 100644 bot/exts/off_topic_names.py create mode 100644 bot/exts/utils/__init__.py create mode 100644 bot/exts/utils/bot.py create mode 100644 bot/exts/utils/clean.py create mode 100644 bot/exts/utils/eval.py create mode 100644 bot/exts/utils/extensions.py create mode 100644 bot/exts/utils/jams.py create mode 100644 bot/exts/utils/reminders.py create mode 100644 bot/exts/utils/snekbox.py create mode 100644 bot/exts/utils/utils.py delete mode 100644 tests/bot/cogs/__init__.py delete mode 100644 tests/bot/cogs/backend/__init__.py delete mode 100644 tests/bot/cogs/backend/sync/__init__.py delete mode 100644 tests/bot/cogs/backend/sync/test_base.py delete mode 100644 tests/bot/cogs/backend/sync/test_cog.py delete mode 100644 tests/bot/cogs/backend/sync/test_roles.py delete mode 100644 tests/bot/cogs/backend/sync/test_users.py delete mode 100644 tests/bot/cogs/backend/test_logging.py delete mode 100644 tests/bot/cogs/filters/__init__.py delete mode 100644 tests/bot/cogs/filters/test_antimalware.py delete mode 100644 tests/bot/cogs/filters/test_antispam.py delete mode 100644 tests/bot/cogs/filters/test_security.py delete mode 100644 tests/bot/cogs/filters/test_token_remover.py delete mode 100644 tests/bot/cogs/info/__init__.py delete mode 100644 tests/bot/cogs/info/test_information.py delete mode 100644 tests/bot/cogs/moderation/__init__.py delete mode 100644 tests/bot/cogs/moderation/infraction/__init__.py delete mode 100644 tests/bot/cogs/moderation/infraction/test_infractions.py delete mode 100644 tests/bot/cogs/moderation/test_incidents.py delete mode 100644 tests/bot/cogs/moderation/test_modlog.py delete mode 100644 tests/bot/cogs/moderation/test_silence.py delete mode 100644 tests/bot/cogs/moderation/test_slowmode.py delete mode 100644 tests/bot/cogs/test_cogs.py delete mode 100644 tests/bot/cogs/test_duck_pond.py delete mode 100644 tests/bot/cogs/utils/__init__.py delete mode 100644 tests/bot/cogs/utils/test_jams.py delete mode 100644 tests/bot/cogs/utils/test_snekbox.py create mode 100644 tests/bot/exts/__init__.py create mode 100644 tests/bot/exts/backend/__init__.py create mode 100644 tests/bot/exts/backend/sync/__init__.py create mode 100644 tests/bot/exts/backend/sync/test_base.py create mode 100644 tests/bot/exts/backend/sync/test_cog.py create mode 100644 tests/bot/exts/backend/sync/test_roles.py create mode 100644 tests/bot/exts/backend/sync/test_users.py create mode 100644 tests/bot/exts/backend/test_logging.py create mode 100644 tests/bot/exts/filters/__init__.py create mode 100644 tests/bot/exts/filters/test_antimalware.py create mode 100644 tests/bot/exts/filters/test_antispam.py create mode 100644 tests/bot/exts/filters/test_security.py create mode 100644 tests/bot/exts/filters/test_token_remover.py create mode 100644 tests/bot/exts/info/__init__.py create mode 100644 tests/bot/exts/info/test_information.py create mode 100644 tests/bot/exts/moderation/__init__.py create mode 100644 tests/bot/exts/moderation/infraction/__init__.py create mode 100644 tests/bot/exts/moderation/infraction/test_infractions.py create mode 100644 tests/bot/exts/moderation/test_incidents.py create mode 100644 tests/bot/exts/moderation/test_modlog.py create mode 100644 tests/bot/exts/moderation/test_silence.py create mode 100644 tests/bot/exts/moderation/test_slowmode.py create mode 100644 tests/bot/exts/test_cogs.py create mode 100644 tests/bot/exts/test_duck_pond.py create mode 100644 tests/bot/exts/utils/__init__.py create mode 100644 tests/bot/exts/utils/test_jams.py create mode 100644 tests/bot/exts/utils/test_snekbox.py diff --git a/bot/__main__.py b/bot/__main__.py index 4b0f6dfe4..555847357 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -34,67 +34,67 @@ bot = Bot( ) # Backend -bot.load_extension("bot.cogs.backend.config_verifier") -bot.load_extension("bot.cogs.backend.error_handler") -bot.load_extension("bot.cogs.backend.logging") -bot.load_extension("bot.cogs.backend.sync") +bot.load_extension("bot.exts.backend.config_verifier") +bot.load_extension("bot.exts.backend.error_handler") +bot.load_extension("bot.exts.backend.logging") +bot.load_extension("bot.exts.backend.sync") # Filters -bot.load_extension("bot.cogs.filters.antimalware") -bot.load_extension("bot.cogs.filters.antispam") -bot.load_extension("bot.cogs.filters.filter_lists") -bot.load_extension("bot.cogs.filters.filtering") -bot.load_extension("bot.cogs.filters.security") -bot.load_extension("bot.cogs.filters.token_remover") -bot.load_extension("bot.cogs.filters.webhook_remover") +bot.load_extension("bot.exts.filters.antimalware") +bot.load_extension("bot.exts.filters.antispam") +bot.load_extension("bot.exts.filters.filter_lists") +bot.load_extension("bot.exts.filters.filtering") +bot.load_extension("bot.exts.filters.security") +bot.load_extension("bot.exts.filters.token_remover") +bot.load_extension("bot.exts.filters.webhook_remover") # Info -bot.load_extension("bot.cogs.info.doc") -bot.load_extension("bot.cogs.info.help") -bot.load_extension("bot.cogs.info.information") -bot.load_extension("bot.cogs.info.python_news") -bot.load_extension("bot.cogs.info.reddit") -bot.load_extension("bot.cogs.info.site") -bot.load_extension("bot.cogs.info.source") -bot.load_extension("bot.cogs.info.stats") -bot.load_extension("bot.cogs.info.tags") -bot.load_extension("bot.cogs.info.wolfram") +bot.load_extension("bot.exts.info.doc") +bot.load_extension("bot.exts.info.help") +bot.load_extension("bot.exts.info.information") +bot.load_extension("bot.exts.info.python_news") +bot.load_extension("bot.exts.info.reddit") +bot.load_extension("bot.exts.info.site") +bot.load_extension("bot.exts.info.source") +bot.load_extension("bot.exts.info.stats") +bot.load_extension("bot.exts.info.tags") +bot.load_extension("bot.exts.info.wolfram") # Moderation -bot.load_extension("bot.cogs.moderation.defcon") -bot.load_extension("bot.cogs.moderation.incidents") -bot.load_extension("bot.cogs.moderation.modlog") -bot.load_extension("bot.cogs.moderation.silence") -bot.load_extension("bot.cogs.moderation.slowmode") -bot.load_extension("bot.cogs.moderation.verification") +bot.load_extension("bot.exts.moderation.defcon") +bot.load_extension("bot.exts.moderation.incidents") +bot.load_extension("bot.exts.moderation.modlog") +bot.load_extension("bot.exts.moderation.silence") +bot.load_extension("bot.exts.moderation.slowmode") +bot.load_extension("bot.exts.moderation.verification") # Moderation - Infraction -bot.load_extension("bot.cogs.moderation.infraction.infractions") -bot.load_extension("bot.cogs.moderation.infraction.management") -bot.load_extension("bot.cogs.moderation.infraction.superstarify") +bot.load_extension("bot.exts.moderation.infraction.infractions") +bot.load_extension("bot.exts.moderation.infraction.management") +bot.load_extension("bot.exts.moderation.infraction.superstarify") # Moderation - Watchchannels -bot.load_extension("bot.cogs.moderation.watchchannels.bigbrother") -bot.load_extension("bot.cogs.moderation.watchchannels.talentpool") +bot.load_extension("bot.exts.moderation.watchchannels.bigbrother") +bot.load_extension("bot.exts.moderation.watchchannels.talentpool") # Utils -bot.load_extension("bot.cogs.utils.bot") -bot.load_extension("bot.cogs.utils.clean") -bot.load_extension("bot.cogs.utils.eval") -bot.load_extension("bot.cogs.utils.extensions") -bot.load_extension("bot.cogs.utils.jams") -bot.load_extension("bot.cogs.utils.reminders") -bot.load_extension("bot.cogs.utils.snekbox") -bot.load_extension("bot.cogs.utils.utils") +bot.load_extension("bot.exts.utils.bot") +bot.load_extension("bot.exts.utils.clean") +bot.load_extension("bot.exts.utils.eval") +bot.load_extension("bot.exts.utils.extensions") +bot.load_extension("bot.exts.utils.jams") +bot.load_extension("bot.exts.utils.reminders") +bot.load_extension("bot.exts.utils.snekbox") +bot.load_extension("bot.exts.utils.utils") # Misc -bot.load_extension("bot.cogs.alias") -bot.load_extension("bot.cogs.dm_relay") -bot.load_extension("bot.cogs.duck_pond") -bot.load_extension("bot.cogs.off_topic_names") +bot.load_extension("bot.exts.alias") +bot.load_extension("bot.exts.dm_relay") +bot.load_extension("bot.exts.duck_pond") +bot.load_extension("bot.exts.off_topic_names") if constants.HelpChannels.enable: - bot.load_extension("bot.cogs.help_channels") + bot.load_extension("bot.exts.help_channels") # Apply `message_edited_at` patch if discord.py did not yet release a bug fix. if not hasattr(discord.message.Message, '_handle_edited_timestamp'): diff --git a/bot/cogs/__init__.py b/bot/cogs/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py deleted file mode 100644 index 3c5a35c24..000000000 --- a/bot/cogs/alias.py +++ /dev/null @@ -1,153 +0,0 @@ -import inspect -import logging - -from discord import Colour, Embed -from discord.ext.commands import ( - Cog, Command, Context, Greedy, - clean_content, command, group, -) - -from bot.bot import Bot -from bot.cogs.utils.extensions import Extension -from bot.converters import FetchedMember, TagNameConverter -from bot.pagination import LinePaginator - -log = logging.getLogger(__name__) - - -class Alias (Cog): - """Aliases for commonly used commands.""" - - def __init__(self, bot: Bot): - self.bot = bot - - async def invoke(self, ctx: Context, cmd_name: str, *args, **kwargs) -> None: - """Invokes a command with args and kwargs.""" - log.debug(f"{cmd_name} was invoked through an alias") - cmd = self.bot.get_command(cmd_name) - if not cmd: - return log.info(f'Did not find command "{cmd_name}" to invoke.') - elif not await cmd.can_run(ctx): - return log.info( - f'{str(ctx.author)} tried to run the command "{cmd_name}" but lacks permission.' - ) - - await ctx.invoke(cmd, *args, **kwargs) - - @command(name='aliases') - async def aliases_command(self, ctx: Context) -> None: - """Show configured aliases on the bot.""" - embed = Embed( - title='Configured aliases', - colour=Colour.blue() - ) - await LinePaginator.paginate( - ( - f"• `{ctx.prefix}{value.name}` " - f"=> `{ctx.prefix}{name[:-len('_alias')].replace('_', ' ')}`" - for name, value in inspect.getmembers(self) - if isinstance(value, Command) and name.endswith('_alias') - ), - ctx, embed, empty=False, max_lines=20 - ) - - @command(name="resources", aliases=("resource",), hidden=True) - async def site_resources_alias(self, ctx: Context) -> None: - """Alias for invoking site resources.""" - await self.invoke(ctx, "site resources") - - @command(name="tools", hidden=True) - async def site_tools_alias(self, ctx: Context) -> None: - """Alias for invoking site tools.""" - await self.invoke(ctx, "site tools") - - @command(name="watch", hidden=True) - async def bigbrother_watch_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """Alias for invoking bigbrother watch [user] [reason].""" - await self.invoke(ctx, "bigbrother watch", user, reason=reason) - - @command(name="unwatch", hidden=True) - async def bigbrother_unwatch_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """Alias for invoking bigbrother unwatch [user] [reason].""" - await self.invoke(ctx, "bigbrother unwatch", user, reason=reason) - - @command(name="home", hidden=True) - async def site_home_alias(self, ctx: Context) -> None: - """Alias for invoking site home.""" - await self.invoke(ctx, "site home") - - @command(name="faq", hidden=True) - async def site_faq_alias(self, ctx: Context) -> None: - """Alias for invoking site faq.""" - await self.invoke(ctx, "site faq") - - @command(name="rules", aliases=("rule",), hidden=True) - async def site_rules_alias(self, ctx: Context, rules: Greedy[int], *_: str) -> None: - """Alias for invoking site rules.""" - await self.invoke(ctx, "site rules", *rules) - - @command(name="reload", hidden=True) - async def extensions_reload_alias(self, ctx: Context, *extensions: Extension) -> None: - """Alias for invoking extensions reload [extensions...].""" - await self.invoke(ctx, "extensions reload", *extensions) - - @command(name="defon", hidden=True) - async def defcon_enable_alias(self, ctx: Context) -> None: - """Alias for invoking defcon enable.""" - await self.invoke(ctx, "defcon enable") - - @command(name="defoff", hidden=True) - async def defcon_disable_alias(self, ctx: Context) -> None: - """Alias for invoking defcon disable.""" - await self.invoke(ctx, "defcon disable") - - @command(name="exception", hidden=True) - async def tags_get_traceback_alias(self, ctx: Context) -> None: - """Alias for invoking tags get traceback.""" - await self.invoke(ctx, "tags get", tag_name="traceback") - - @group(name="get", - aliases=("show", "g"), - hidden=True, - invoke_without_command=True) - async def get_group_alias(self, ctx: Context) -> None: - """Group for reverse aliases for commands like `tags get`, allowing for `get tags` or `get docs`.""" - pass - - @get_group_alias.command(name="tags", aliases=("tag", "t"), hidden=True) - async def tags_get_alias( - self, ctx: Context, *, tag_name: TagNameConverter = None - ) -> None: - """ - Alias for invoking tags get [tag_name]. - - tag_name: str - tag to be viewed. - """ - await self.invoke(ctx, "tags get", tag_name=tag_name) - - @get_group_alias.command(name="docs", aliases=("doc", "d"), hidden=True) - async def docs_get_alias( - self, ctx: Context, symbol: clean_content = None - ) -> None: - """Alias for invoking docs get [symbol].""" - await self.invoke(ctx, "docs get", symbol) - - @command(name="nominate", hidden=True) - async def nomination_add_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """Alias for invoking talentpool add [user] [reason].""" - await self.invoke(ctx, "talentpool add", user, reason=reason) - - @command(name="unnominate", hidden=True) - async def nomination_end_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """Alias for invoking nomination end [user] [reason].""" - await self.invoke(ctx, "nomination end", user, reason=reason) - - @command(name="nominees", hidden=True) - async def nominees_alias(self, ctx: Context) -> None: - """Alias for invoking tp watched.""" - await self.invoke(ctx, "talentpool watched") - - -def setup(bot: Bot) -> None: - """Load the Alias cog.""" - bot.add_cog(Alias(bot)) diff --git a/bot/cogs/backend/__init__.py b/bot/cogs/backend/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/bot/cogs/backend/config_verifier.py b/bot/cogs/backend/config_verifier.py deleted file mode 100644 index d72c6c22e..000000000 --- a/bot/cogs/backend/config_verifier.py +++ /dev/null @@ -1,40 +0,0 @@ -import logging - -from discord.ext.commands import Cog - -from bot import constants -from bot.bot import Bot - - -log = logging.getLogger(__name__) - - -class ConfigVerifier(Cog): - """Verify config on startup.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.channel_verify_task = self.bot.loop.create_task(self.verify_channels()) - - async def verify_channels(self) -> None: - """ - Verify channels. - - If any channels in config aren't present in server, log them in a warning. - """ - await self.bot.wait_until_guild_available() - server = self.bot.get_guild(constants.Guild.id) - - server_channel_ids = {channel.id for channel in server.channels} - invalid_channels = [ - channel_name for channel_name, channel_id in constants.Channels - if channel_id not in server_channel_ids - ] - - if invalid_channels: - log.warning(f"Configured channels do not exist in server: {', '.join(invalid_channels)}.") - - -def setup(bot: Bot) -> None: - """Load the ConfigVerifier cog.""" - bot.add_cog(ConfigVerifier(bot)) diff --git a/bot/cogs/backend/error_handler.py b/bot/cogs/backend/error_handler.py deleted file mode 100644 index f9d4de638..000000000 --- a/bot/cogs/backend/error_handler.py +++ /dev/null @@ -1,287 +0,0 @@ -import contextlib -import logging -import typing as t - -from discord import Embed -from discord.ext.commands import Cog, Context, errors -from sentry_sdk import push_scope - -from bot.api import ResponseCodeError -from bot.bot import Bot -from bot.constants import Channels, Colours -from bot.converters import TagNameConverter -from bot.utils.checks import InWhitelistCheckFailure - -log = logging.getLogger(__name__) - - -class ErrorHandler(Cog): - """Handles errors emitted from commands.""" - - def __init__(self, bot: Bot): - self.bot = bot - - def _get_error_embed(self, title: str, body: str) -> Embed: - """Return an embed that contains the exception.""" - return Embed( - title=title, - colour=Colours.soft_red, - description=body - ) - - @Cog.listener() - async def on_command_error(self, ctx: Context, e: errors.CommandError) -> None: - """ - Provide generic command error handling. - - Error handling is deferred to any local error handler, if present. This is done by - checking for the presence of a `handled` attribute on the error. - - Error handling emits a single error message in the invoking context `ctx` and a log message, - prioritised as follows: - - 1. If the name fails to match a command: - * If it matches shh+ or unshh+, the channel is silenced or unsilenced respectively. - Otherwise if it matches a tag, the tag is invoked - * If CommandNotFound is raised when invoking the tag (determined by the presence of the - `invoked_from_error_handler` attribute), this error is treated as being unexpected - and therefore sends an error message - * Commands in the verification channel are ignored - 2. UserInputError: see `handle_user_input_error` - 3. CheckFailure: see `handle_check_failure` - 4. CommandOnCooldown: send an error message in the invoking context - 5. ResponseCodeError: see `handle_api_error` - 6. Otherwise, if not a DisabledCommand, handling is deferred to `handle_unexpected_error` - """ - command = ctx.command - - if hasattr(e, "handled"): - 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 await self.try_silence(ctx): - return - if ctx.channel.id != Channels.verification: - # Try to look for a tag with the command's name - await self.try_get_tag(ctx) - return # Exit early to avoid logging. - elif isinstance(e, errors.UserInputError): - await self.handle_user_input_error(ctx, e) - elif isinstance(e, errors.CheckFailure): - await self.handle_check_failure(ctx, e) - elif isinstance(e, errors.CommandOnCooldown): - await ctx.send(e) - elif isinstance(e, errors.CommandInvokeError): - if isinstance(e.original, ResponseCodeError): - await self.handle_api_error(ctx, e.original) - else: - await self.handle_unexpected_error(ctx, e.original) - return # Exit early to avoid logging. - elif not isinstance(e, errors.DisabledCommand): - # ConversionError, MaxConcurrencyReached, ExtensionError - await self.handle_unexpected_error(ctx, e) - return # Exit early to avoid logging. - - log.debug( - f"Command {command} invoked by {ctx.message.author} with error " - f"{e.__class__.__name__}: {e}" - ) - - @staticmethod - def get_help_command(ctx: Context) -> t.Coroutine: - """Return a prepared `help` command invocation coroutine.""" - if ctx.command: - return ctx.send_help(ctx.command) - - return ctx.send_help() - - async def try_silence(self, ctx: Context) -> bool: - """ - Attempt to invoke the silence or unsilence command if invoke with matches a pattern. - - Respecting the checks if: - * invoked with `shh+` silence channel for amount of h's*2 with max of 15. - * invoked with `unshh+` unsilence channel - Return bool depending on success of command. - """ - command = ctx.invoked_with.lower() - silence_command = self.bot.get_command("silence") - ctx.invoked_from_error_handler = True - try: - if not await silence_command.can_run(ctx): - log.debug("Cancelling attempt to invoke silence/unsilence due to failed checks.") - return False - except errors.CommandError: - log.debug("Cancelling attempt to invoke silence/unsilence due to failed checks.") - return False - if command.startswith("shh"): - await ctx.invoke(silence_command, duration=min(command.count("h")*2, 15)) - return True - elif command.startswith("unshh"): - await ctx.invoke(self.bot.get_command("unsilence")) - return True - return False - - async def try_get_tag(self, ctx: Context) -> None: - """ - Attempt to display a tag by interpreting the command name as a tag name. - - The invocation of tags get respects its checks. Any CommandErrors raised will be handled - by `on_command_error`, but the `invoked_from_error_handler` attribute will be added to - the context to prevent infinite recursion in the case of a CommandNotFound exception. - """ - tags_get_command = self.bot.get_command("tags get") - ctx.invoked_from_error_handler = True - - log_msg = "Cancelling attempt to fall back to a tag due to failed checks." - try: - if not await tags_get_command.can_run(ctx): - log.debug(log_msg) - return - except errors.CommandError as tag_error: - log.debug(log_msg) - await self.on_command_error(ctx, tag_error) - return - - try: - tag_name = await TagNameConverter.convert(ctx, ctx.invoked_with) - except errors.BadArgument: - log.debug( - f"{ctx.author} tried to use an invalid command " - f"and the fallback tag failed validation in TagNameConverter." - ) - else: - with contextlib.suppress(ResponseCodeError): - await ctx.invoke(tags_get_command, tag_name=tag_name) - # Return to not raise the exception - return - - async def handle_user_input_error(self, ctx: Context, e: errors.UserInputError) -> None: - """ - Send an error message in `ctx` for UserInputError, sometimes invoking the help command too. - - * MissingRequiredArgument: send an error message with arg name and the help command - * TooManyArguments: send an error message and the help command - * BadArgument: send an error message and the help command - * BadUnionArgument: send an error message including the error produced by the last converter - * 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): - embed = self._get_error_embed("Missing required argument", e.param.name) - await ctx.send(embed=embed) - await prepared_help_command - self.bot.stats.incr("errors.missing_required_argument") - elif isinstance(e, errors.TooManyArguments): - embed = self._get_error_embed("Too many arguments", str(e)) - await ctx.send(embed=embed) - await prepared_help_command - self.bot.stats.incr("errors.too_many_arguments") - elif isinstance(e, errors.BadArgument): - embed = self._get_error_embed("Bad argument", str(e)) - await ctx.send(embed=embed) - await prepared_help_command - self.bot.stats.incr("errors.bad_argument") - elif isinstance(e, errors.BadUnionArgument): - embed = self._get_error_embed("Bad argument", f"{e}\n{e.errors[-1]}") - await ctx.send(embed=embed) - 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) - self.bot.stats.incr("errors.argument_parsing_error") - else: - embed = self._get_error_embed( - "Input error", - "Something about your input seems off. Check the arguments and try again." - ) - await ctx.send(embed=embed) - await prepared_help_command - self.bot.stats.incr("errors.other_user_input_error") - - @staticmethod - async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None: - """ - Send an error message in `ctx` for certain types of CheckFailure. - - The following types are handled: - - * BotMissingPermissions - * BotMissingRole - * BotMissingAnyRole - * NoPrivateMessage - * InWhitelistCheckFailure - """ - bot_missing_errors = ( - errors.BotMissingPermissions, - errors.BotMissingRole, - errors.BotMissingAnyRole - ) - - if isinstance(e, bot_missing_errors): - ctx.bot.stats.incr("errors.bot_permission_error") - await ctx.send( - "Sorry, it looks like I don't have the permissions or roles I need to do that." - ) - elif isinstance(e, (InWhitelistCheckFailure, errors.NoPrivateMessage)): - ctx.bot.stats.incr("errors.wrong_channel_or_dm_error") - await ctx.send(e) - - @staticmethod - async def handle_api_error(ctx: Context, e: ResponseCodeError) -> None: - """Send an error message in `ctx` for ResponseCodeError and log it.""" - if e.status == 404: - await ctx.send("There does not seem to be anything matching your query.") - log.debug(f"API responded with 404 for command {ctx.command}") - ctx.bot.stats.incr("errors.api_error_404") - elif e.status == 400: - content = await e.response.json() - log.debug(f"API responded with 400 for command {ctx.command}: %r.", content) - await ctx.send("According to the API, your request is malformed.") - ctx.bot.stats.incr("errors.api_error_400") - elif 500 <= e.status < 600: - await ctx.send("Sorry, there seems to be an internal issue with the API.") - log.warning(f"API responded with {e.status} for command {ctx.command}") - ctx.bot.stats.incr("errors.api_internal_server_error") - else: - await ctx.send(f"Got an unexpected status code from the API (`{e.status}`).") - log.warning(f"Unexpected API response for command {ctx.command}: {e.status}") - ctx.bot.stats.incr(f"errors.api_error_{e.status}") - - @staticmethod - async def handle_unexpected_error(ctx: Context, e: errors.CommandError) -> None: - """Send a generic error message in `ctx` and log the exception as an error with exc_info.""" - await ctx.send( - f"Sorry, an unexpected error occurred. Please let us know!\n\n" - f"```{e.__class__.__name__}: {e}```" - ) - - ctx.bot.stats.incr("errors.unexpected") - - with push_scope() as scope: - scope.user = { - "id": ctx.author.id, - "username": str(ctx.author) - } - - scope.set_tag("command", ctx.command.qualified_name) - scope.set_tag("message_id", ctx.message.id) - scope.set_tag("channel_id", ctx.channel.id) - - scope.set_extra("full_message", ctx.message.content) - - if ctx.guild is not None: - scope.set_extra( - "jump_to", - f"https://discordapp.com/channels/{ctx.guild.id}/{ctx.channel.id}/{ctx.message.id}" - ) - - log.error(f"Error executing command invoked by {ctx.message.author}: {ctx.message.content}", exc_info=e) - - -def setup(bot: Bot) -> None: - """Load the ErrorHandler cog.""" - bot.add_cog(ErrorHandler(bot)) diff --git a/bot/cogs/backend/logging.py b/bot/cogs/backend/logging.py deleted file mode 100644 index 94fa2b139..000000000 --- a/bot/cogs/backend/logging.py +++ /dev/null @@ -1,42 +0,0 @@ -import logging - -from discord import Embed -from discord.ext.commands import Cog - -from bot.bot import Bot -from bot.constants import Channels, DEBUG_MODE - - -log = logging.getLogger(__name__) - - -class Logging(Cog): - """Debug logging module.""" - - def __init__(self, bot: Bot): - self.bot = bot - - self.bot.loop.create_task(self.startup_greeting()) - - async def startup_greeting(self) -> None: - """Announce our presence to the configured devlog channel.""" - await self.bot.wait_until_guild_available() - log.info("Bot connected!") - - embed = Embed(description="Connected!") - embed.set_author( - name="Python Bot", - url="https://github.com/python-discord/bot", - icon_url=( - "https://raw.githubusercontent.com/" - "python-discord/branding/master/logos/logo_circle/logo_circle_large.png" - ) - ) - - if not DEBUG_MODE: - await self.bot.get_channel(Channels.dev_log).send(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the Logging cog.""" - bot.add_cog(Logging(bot)) diff --git a/bot/cogs/backend/sync/__init__.py b/bot/cogs/backend/sync/__init__.py deleted file mode 100644 index 2541beaa8..000000000 --- a/bot/cogs/backend/sync/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from bot.bot import Bot - - -def setup(bot: Bot) -> None: - """Load the Sync cog.""" - from ._cog import Sync - bot.add_cog(Sync(bot)) diff --git a/bot/cogs/backend/sync/_cog.py b/bot/cogs/backend/sync/_cog.py deleted file mode 100644 index b6068f328..000000000 --- a/bot/cogs/backend/sync/_cog.py +++ /dev/null @@ -1,180 +0,0 @@ -import logging -from typing import Any, Dict - -from discord import Member, Role, User -from discord.ext import commands -from discord.ext.commands import Cog, Context - -from bot import constants -from bot.api import ResponseCodeError -from bot.bot import Bot -from . import _syncers - -log = logging.getLogger(__name__) - - -class Sync(Cog): - """Captures relevant events and sends them to the site.""" - - def __init__(self, bot: Bot) -> None: - self.bot = bot - self.role_syncer = _syncers.RoleSyncer(self.bot) - self.user_syncer = _syncers.UserSyncer(self.bot) - - self.bot.loop.create_task(self.sync_guild()) - - async def sync_guild(self) -> None: - """Syncs the roles/users of the guild with the database.""" - await self.bot.wait_until_guild_available() - - guild = self.bot.get_guild(constants.Guild.id) - if guild is None: - return - - for syncer in (self.role_syncer, self.user_syncer): - await syncer.sync(guild) - - async def patch_user(self, user_id: int, json: Dict[str, Any], ignore_404: bool = False) -> None: - """Send a PATCH request to partially update a user in the database.""" - try: - await self.bot.api_client.patch(f"bot/users/{user_id}", json=json) - except ResponseCodeError as e: - if e.response.status != 404: - raise - if not ignore_404: - log.warning("Unable to update user, got 404. Assuming race condition from join event.") - - @Cog.listener() - async def on_guild_role_create(self, role: Role) -> None: - """Adds newly create role to the database table over the API.""" - if role.guild.id != constants.Guild.id: - return - - await self.bot.api_client.post( - 'bot/roles', - json={ - 'colour': role.colour.value, - 'id': role.id, - 'name': role.name, - 'permissions': role.permissions.value, - 'position': role.position, - } - ) - - @Cog.listener() - async def on_guild_role_delete(self, role: Role) -> None: - """Deletes role from the database when it's deleted from the guild.""" - if role.guild.id != constants.Guild.id: - return - - await self.bot.api_client.delete(f'bot/roles/{role.id}') - - @Cog.listener() - async def on_guild_role_update(self, before: Role, after: Role) -> None: - """Syncs role with the database if any of the stored attributes were updated.""" - if after.guild.id != constants.Guild.id: - return - - was_updated = ( - before.name != after.name - or before.colour != after.colour - or before.permissions != after.permissions - or before.position != after.position - ) - - if was_updated: - await self.bot.api_client.put( - f'bot/roles/{after.id}', - json={ - 'colour': after.colour.value, - 'id': after.id, - 'name': after.name, - 'permissions': after.permissions.value, - 'position': after.position, - } - ) - - @Cog.listener() - async def on_member_join(self, member: Member) -> None: - """ - Adds a new user or updates existing user to the database when a member joins the guild. - - If the joining member is a user that is already known to the database (i.e., a user that - previously left), it will update the user's information. If the user is not yet known by - the database, the user is added. - """ - if member.guild.id != constants.Guild.id: - return - - packed = { - 'discriminator': int(member.discriminator), - 'id': member.id, - 'in_guild': True, - 'name': member.name, - 'roles': sorted(role.id for role in member.roles) - } - - got_error = False - - try: - # First try an update of the user to set the `in_guild` field and other - # fields that may have changed since the last time we've seen them. - await self.bot.api_client.put(f'bot/users/{member.id}', json=packed) - - except ResponseCodeError as e: - # If we didn't get 404, something else broke - propagate it up. - if e.response.status != 404: - raise - - got_error = True # yikes - - if got_error: - # If we got `404`, the user is new. Create them. - await self.bot.api_client.post('bot/users', json=packed) - - @Cog.listener() - async def on_member_remove(self, member: Member) -> None: - """Set the in_guild field to False when a member leaves the guild.""" - if member.guild.id != constants.Guild.id: - return - - await self.patch_user(member.id, json={"in_guild": False}) - - @Cog.listener() - async def on_member_update(self, before: Member, after: Member) -> None: - """Update the roles of the member in the database if a change is detected.""" - if after.guild.id != constants.Guild.id: - return - - if before.roles != after.roles: - updated_information = {"roles": sorted(role.id for role in after.roles)} - await self.patch_user(after.id, json=updated_information) - - @Cog.listener() - async def on_user_update(self, before: User, after: User) -> None: - """Update the user information in the database if a relevant change is detected.""" - attrs = ("name", "discriminator") - if any(getattr(before, attr) != getattr(after, attr) for attr in attrs): - updated_information = { - "name": after.name, - "discriminator": int(after.discriminator), - } - # A 404 likely means the user is in another guild. - await self.patch_user(after.id, json=updated_information, ignore_404=True) - - @commands.group(name='sync') - @commands.has_permissions(administrator=True) - async def sync_group(self, ctx: Context) -> None: - """Run synchronizations between the bot and site manually.""" - - @sync_group.command(name='roles') - @commands.has_permissions(administrator=True) - async def sync_roles_command(self, ctx: Context) -> None: - """Manually synchronise the guild's roles with the roles on the site.""" - await self.role_syncer.sync(ctx.guild, ctx) - - @sync_group.command(name='users') - @commands.has_permissions(administrator=True) - async def sync_users_command(self, ctx: Context) -> None: - """Manually synchronise the guild's users with the users on the site.""" - await self.user_syncer.sync(ctx.guild, ctx) diff --git a/bot/cogs/backend/sync/_syncers.py b/bot/cogs/backend/sync/_syncers.py deleted file mode 100644 index f7ba811bc..000000000 --- a/bot/cogs/backend/sync/_syncers.py +++ /dev/null @@ -1,347 +0,0 @@ -import abc -import asyncio -import logging -import typing as t -from collections import namedtuple -from functools import partial - -import discord -from discord import Guild, HTTPException, Member, Message, Reaction, User -from discord.ext.commands import Context - -from bot import constants -from bot.api import ResponseCodeError -from bot.bot import Bot - -log = logging.getLogger(__name__) - -# These objects are declared as namedtuples because tuples are hashable, -# something that we make use of when diffing site roles against guild roles. -_Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions', 'position')) -_User = namedtuple('User', ('id', 'name', 'discriminator', 'roles', 'in_guild')) -_Diff = namedtuple('Diff', ('created', 'updated', 'deleted')) - - -class Syncer(abc.ABC): - """Base class for synchronising the database with objects in the Discord cache.""" - - _CORE_DEV_MENTION = f"<@&{constants.Roles.core_developers}> " - _REACTION_EMOJIS = (constants.Emojis.check_mark, constants.Emojis.cross_mark) - - def __init__(self, bot: Bot) -> None: - self.bot = bot - - @property - @abc.abstractmethod - def name(self) -> str: - """The name of the syncer; used in output messages and logging.""" - raise NotImplementedError # pragma: no cover - - async def _send_prompt(self, message: t.Optional[Message] = None) -> t.Optional[Message]: - """ - Send a prompt to confirm or abort a sync using reactions and return the sent message. - - If a message is given, it is edited to display the prompt and reactions. Otherwise, a new - message is sent to the dev-core channel and mentions the core developers role. If the - channel cannot be retrieved, return None. - """ - log.trace(f"Sending {self.name} sync confirmation prompt.") - - msg_content = ( - f'Possible cache issue while syncing {self.name}s. ' - f'More than {constants.Sync.max_diff} {self.name}s were changed. ' - f'React to confirm or abort the sync.' - ) - - # Send to core developers if it's an automatic sync. - if not message: - log.trace("Message not provided for confirmation; creating a new one in dev-core.") - channel = self.bot.get_channel(constants.Channels.dev_core) - - if not channel: - log.debug("Failed to get the dev-core channel from cache; attempting to fetch it.") - try: - channel = await self.bot.fetch_channel(constants.Channels.dev_core) - except HTTPException: - log.exception( - f"Failed to fetch channel for sending sync confirmation prompt; " - f"aborting {self.name} sync." - ) - return None - - allowed_roles = [discord.Object(constants.Roles.core_developers)] - message = await channel.send( - f"{self._CORE_DEV_MENTION}{msg_content}", - allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) - ) - else: - await message.edit(content=msg_content) - - # Add the initial reactions. - log.trace(f"Adding reactions to {self.name} syncer confirmation prompt.") - for emoji in self._REACTION_EMOJIS: - await message.add_reaction(emoji) - - return message - - def _reaction_check( - self, - author: Member, - message: Message, - reaction: Reaction, - user: t.Union[Member, User] - ) -> bool: - """ - Return True if the `reaction` is a valid confirmation or abort reaction on `message`. - - If the `author` of the prompt is a bot, then a reaction by any core developer will be - considered valid. Otherwise, the author of the reaction (`user`) will have to be the - `author` of the prompt. - """ - # For automatic syncs, check for the core dev role instead of an exact author - has_role = any(constants.Roles.core_developers == role.id for role in user.roles) - return ( - reaction.message.id == message.id - and not user.bot - and (has_role if author.bot else user == author) - and str(reaction.emoji) in self._REACTION_EMOJIS - ) - - async def _wait_for_confirmation(self, author: Member, message: Message) -> bool: - """ - Wait for a confirmation reaction by `author` on `message` and return True if confirmed. - - Uses the `_reaction_check` function to determine if a reaction is valid. - - If there is no reaction within `bot.constants.Sync.confirm_timeout` seconds, return False. - To acknowledge the reaction (or lack thereof), `message` will be edited. - """ - # Preserve the core-dev role mention in the message edits so users aren't confused about - # where notifications came from. - mention = self._CORE_DEV_MENTION if author.bot else "" - - reaction = None - try: - log.trace(f"Waiting for a reaction to the {self.name} syncer confirmation prompt.") - reaction, _ = await self.bot.wait_for( - 'reaction_add', - check=partial(self._reaction_check, author, message), - timeout=constants.Sync.confirm_timeout - ) - except asyncio.TimeoutError: - # reaction will remain none thus sync will be aborted in the finally block below. - log.debug(f"The {self.name} syncer confirmation prompt timed out.") - - if str(reaction) == constants.Emojis.check_mark: - log.trace(f"The {self.name} syncer was confirmed.") - await message.edit(content=f':ok_hand: {mention}{self.name} sync will proceed.') - return True - else: - log.info(f"The {self.name} syncer was aborted or timed out!") - await message.edit( - content=f':warning: {mention}{self.name} sync aborted or timed out!' - ) - return False - - @abc.abstractmethod - async def _get_diff(self, guild: Guild) -> _Diff: - """Return the difference between the cache of `guild` and the database.""" - raise NotImplementedError # pragma: no cover - - @abc.abstractmethod - async def _sync(self, diff: _Diff) -> None: - """Perform the API calls for synchronisation.""" - raise NotImplementedError # pragma: no cover - - async def _get_confirmation_result( - self, - diff_size: int, - author: Member, - message: t.Optional[Message] = None - ) -> t.Tuple[bool, t.Optional[Message]]: - """ - Prompt for confirmation and return a tuple of the result and the prompt message. - - `diff_size` is the size of the diff of the sync. If it is greater than - `bot.constants.Sync.max_diff`, the prompt will be sent. The `author` is the invoked of the - sync and the `message` is an extant message to edit to display the prompt. - - If confirmed or no confirmation was needed, the result is True. The returned message will - either be the given `message` or a new one which was created when sending the prompt. - """ - log.trace(f"Determining if confirmation prompt should be sent for {self.name} syncer.") - if diff_size > constants.Sync.max_diff: - message = await self._send_prompt(message) - if not message: - return False, None # Couldn't get channel. - - confirmed = await self._wait_for_confirmation(author, message) - if not confirmed: - return False, message # Sync aborted. - - return True, message - - async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> None: - """ - Synchronise the database with the cache of `guild`. - - If the differences between the cache and the database are greater than - `bot.constants.Sync.max_diff`, then a confirmation prompt will be sent to the dev-core - channel. The confirmation can be optionally redirect to `ctx` instead. - """ - log.info(f"Starting {self.name} syncer.") - - message = None - author = self.bot.user - if ctx: - message = await ctx.send(f"📊 Synchronising {self.name}s.") - author = ctx.author - - diff = await self._get_diff(guild) - diff_dict = diff._asdict() # Ugly method for transforming the NamedTuple into a dict - totals = {k: len(v) for k, v in diff_dict.items() if v is not None} - diff_size = sum(totals.values()) - - confirmed, message = await self._get_confirmation_result(diff_size, author, message) - if not confirmed: - return - - # Preserve the core-dev role mention in the message edits so users aren't confused about - # where notifications came from. - mention = self._CORE_DEV_MENTION if author.bot else "" - - try: - await self._sync(diff) - except ResponseCodeError as e: - log.exception(f"{self.name} syncer failed!") - - # Don't show response text because it's probably some really long HTML. - results = f"status {e.status}\n```{e.response_json or 'See log output for details'}```" - content = f":x: {mention}Synchronisation of {self.name}s failed: {results}" - else: - results = ", ".join(f"{name} `{total}`" for name, total in totals.items()) - log.info(f"{self.name} syncer finished: {results}.") - content = f":ok_hand: {mention}Synchronisation of {self.name}s complete: {results}" - - if message: - await message.edit(content=content) - - -class RoleSyncer(Syncer): - """Synchronise the database with roles in the cache.""" - - name = "role" - - async def _get_diff(self, guild: Guild) -> _Diff: - """Return the difference of roles between the cache of `guild` and the database.""" - log.trace("Getting the diff for roles.") - roles = await self.bot.api_client.get('bot/roles') - - # Pack DB roles and guild roles into one common, hashable format. - # They're hashable so that they're easily comparable with sets later. - db_roles = {_Role(**role_dict) for role_dict in roles} - guild_roles = { - _Role( - id=role.id, - name=role.name, - colour=role.colour.value, - permissions=role.permissions.value, - position=role.position, - ) - for role in guild.roles - } - - guild_role_ids = {role.id for role in guild_roles} - api_role_ids = {role.id for role in db_roles} - new_role_ids = guild_role_ids - api_role_ids - deleted_role_ids = api_role_ids - guild_role_ids - - # New roles are those which are on the cached guild but not on the - # DB guild, going by the role ID. We need to send them in for creation. - roles_to_create = {role for role in guild_roles if role.id in new_role_ids} - roles_to_update = guild_roles - db_roles - roles_to_create - roles_to_delete = {role for role in db_roles if role.id in deleted_role_ids} - - return _Diff(roles_to_create, roles_to_update, roles_to_delete) - - async def _sync(self, diff: _Diff) -> None: - """Synchronise the database with the role cache of `guild`.""" - log.trace("Syncing created roles...") - for role in diff.created: - await self.bot.api_client.post('bot/roles', json=role._asdict()) - - log.trace("Syncing updated roles...") - for role in diff.updated: - await self.bot.api_client.put(f'bot/roles/{role.id}', json=role._asdict()) - - log.trace("Syncing deleted roles...") - for role in diff.deleted: - await self.bot.api_client.delete(f'bot/roles/{role.id}') - - -class UserSyncer(Syncer): - """Synchronise the database with users in the cache.""" - - name = "user" - - async def _get_diff(self, guild: Guild) -> _Diff: - """Return the difference of users between the cache of `guild` and the database.""" - log.trace("Getting the diff for users.") - users = await self.bot.api_client.get('bot/users') - - # Pack DB roles and guild roles into one common, hashable format. - # They're hashable so that they're easily comparable with sets later. - db_users = { - user_dict['id']: _User( - roles=tuple(sorted(user_dict.pop('roles'))), - **user_dict - ) - for user_dict in users - } - guild_users = { - member.id: _User( - id=member.id, - name=member.name, - discriminator=int(member.discriminator), - roles=tuple(sorted(role.id for role in member.roles)), - in_guild=True - ) - for member in guild.members - } - - users_to_create = set() - users_to_update = set() - - for db_user in db_users.values(): - guild_user = guild_users.get(db_user.id) - if guild_user is not None: - if db_user != guild_user: - users_to_update.add(guild_user) - - elif db_user.in_guild: - # The user is known in the DB but not the guild, and the - # DB currently specifies that the user is a member of the guild. - # This means that the user has left since the last sync. - # Update the `in_guild` attribute of the user on the site - # to signify that the user left. - new_api_user = db_user._replace(in_guild=False) - users_to_update.add(new_api_user) - - new_user_ids = set(guild_users.keys()) - set(db_users.keys()) - for user_id in new_user_ids: - # The user is known on the guild but not on the API. This means - # that the user has joined since the last sync. Create it. - new_user = guild_users[user_id] - users_to_create.add(new_user) - - return _Diff(users_to_create, users_to_update, None) - - async def _sync(self, diff: _Diff) -> None: - """Synchronise the database with the user cache of `guild`.""" - log.trace("Syncing created users...") - for user in diff.created: - await self.bot.api_client.post('bot/users', json=user._asdict()) - - log.trace("Syncing updated users...") - for user in diff.updated: - await self.bot.api_client.put(f'bot/users/{user.id}', json=user._asdict()) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py deleted file mode 100644 index 0d8f340b4..000000000 --- a/bot/cogs/dm_relay.py +++ /dev/null @@ -1,124 +0,0 @@ -import logging -from typing import Optional - -import discord -from discord import Color -from discord.ext import commands -from discord.ext.commands import Cog - -from bot import constants -from bot.bot import Bot -from bot.converters import UserMentionOrID -from bot.utils import RedisCache -from bot.utils.checks import in_whitelist_check, with_role_check -from bot.utils.messages import send_attachments -from bot.utils.webhooks import send_webhook - -log = logging.getLogger(__name__) - - -class DMRelay(Cog): - """Relay direct messages to and from the bot.""" - - # RedisCache[str, t.Union[discord.User.id, discord.Member.id]] - dm_cache = RedisCache() - - def __init__(self, bot: Bot): - self.bot = bot - self.webhook_id = constants.Webhooks.dm_log - self.webhook = None - self.bot.loop.create_task(self.fetch_webhook()) - - @commands.command(aliases=("reply",)) - async def send_dm(self, ctx: commands.Context, member: Optional[UserMentionOrID], *, message: str) -> None: - """ - Allows you to send a DM to a user from the bot. - - If `member` is not provided, it will send to the last user who DM'd the bot. - - This feature should be used extremely sparingly. Use ModMail if you need to have a serious - conversation with a user. This is just for responding to extraordinary DMs, having a little - fun with users, and telling people they are DMing the wrong bot. - - NOTE: This feature will be removed if it is overused. - """ - if not member: - user_id = await self.dm_cache.get("last_user") - member = ctx.guild.get_member(user_id) if user_id else None - - # If we still don't have a Member at this point, give up - if not member: - log.debug("This bot has never gotten a DM, or the RedisCache has been cleared.") - await ctx.message.add_reaction("❌") - return - - try: - await member.send(message) - except discord.errors.Forbidden: - log.debug("User has disabled DMs.") - await ctx.message.add_reaction("❌") - else: - await ctx.message.add_reaction("✅") - self.bot.stats.incr("dm_relay.dm_sent") - - async def fetch_webhook(self) -> None: - """Fetches the webhook object, so we can post to it.""" - await self.bot.wait_until_guild_available() - - try: - self.webhook = await self.bot.fetch_webhook(self.webhook_id) - except discord.HTTPException: - log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") - - @Cog.listener() - async def on_message(self, message: discord.Message) -> None: - """Relays the message's content and attachments to the dm_log channel.""" - # Only relay DMs from humans - if message.author.bot or message.guild or self.webhook is None: - return - - if message.clean_content: - await send_webhook( - webhook=self.webhook, - content=message.clean_content, - username=f"{message.author.display_name} ({message.author.id})", - avatar_url=message.author.avatar_url - ) - await self.dm_cache.set("last_user", message.author.id) - self.bot.stats.incr("dm_relay.dm_received") - - # Handle any attachments - if message.attachments: - try: - await send_attachments(message, self.webhook) - except (discord.errors.Forbidden, discord.errors.NotFound): - e = discord.Embed( - description=":x: **This message contained an attachment, but it could not be retrieved**", - color=Color.red() - ) - await send_webhook( - webhook=self.webhook, - embed=e, - username=f"{message.author.display_name} ({message.author.id})", - avatar_url=message.author.avatar_url - ) - except discord.HTTPException: - log.exception("Failed to send an attachment to the webhook") - - def cog_check(self, ctx: commands.Context) -> bool: - """Only allow moderators to invoke the commands in this cog.""" - checks = [ - with_role_check(ctx, *constants.MODERATION_ROLES), - in_whitelist_check( - ctx, - channels=[constants.Channels.dm_log], - redirect=None, - fail_silently=True, - ) - ] - return all(checks) - - -def setup(bot: Bot) -> None: - """Load the DMRelay cog.""" - bot.add_cog(DMRelay(bot)) diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py deleted file mode 100644 index 7021069fa..000000000 --- a/bot/cogs/duck_pond.py +++ /dev/null @@ -1,166 +0,0 @@ -import logging -from typing import Union - -import discord -from discord import Color, Embed, Member, Message, RawReactionActionEvent, User, errors -from discord.ext.commands import Cog - -from bot import constants -from bot.bot import Bot -from bot.utils.messages import send_attachments -from bot.utils.webhooks import send_webhook - -log = logging.getLogger(__name__) - - -class DuckPond(Cog): - """Relays messages to #duck-pond whenever a certain number of duck reactions have been achieved.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.webhook_id = constants.Webhooks.duck_pond - self.webhook = None - self.bot.loop.create_task(self.fetch_webhook()) - - async def fetch_webhook(self) -> None: - """Fetches the webhook object, so we can post to it.""" - await self.bot.wait_until_guild_available() - - try: - self.webhook = await self.bot.fetch_webhook(self.webhook_id) - except discord.HTTPException: - log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") - - @staticmethod - def is_staff(member: Union[User, Member]) -> bool: - """Check if a specific member or user is staff.""" - if hasattr(member, "roles"): - for role in member.roles: - if role.id in constants.STAFF_ROLES: - return True - return False - - async def has_green_checkmark(self, message: Message) -> bool: - """Check if the message has a green checkmark reaction.""" - for reaction in message.reactions: - if reaction.emoji == "✅": - async for user in reaction.users(): - if user == self.bot.user: - return True - return False - - async def count_ducks(self, message: Message) -> int: - """ - Count the number of ducks in the reactions of a specific message. - - Only counts ducks added by staff members. - """ - duck_count = 0 - duck_reactors = [] - - for reaction in message.reactions: - async for user in reaction.users(): - - # Is the user a staff member and not already counted as reactor? - if not self.is_staff(user) or user.id in duck_reactors: - continue - - # Is the emoji a duck? - if hasattr(reaction.emoji, "id"): - if reaction.emoji.id in constants.DuckPond.custom_emojis: - duck_count += 1 - duck_reactors.append(user.id) - elif isinstance(reaction.emoji, str): - if reaction.emoji == "🦆": - duck_count += 1 - duck_reactors.append(user.id) - return duck_count - - async def relay_message(self, message: Message) -> None: - """Relays the message's content and attachments to the duck pond channel.""" - if message.clean_content: - await send_webhook( - webhook=self.webhook, - content=message.clean_content, - username=message.author.display_name, - avatar_url=message.author.avatar_url - ) - - if message.attachments: - try: - await send_attachments(message, self.webhook) - except (errors.Forbidden, errors.NotFound): - e = Embed( - description=":x: **This message contained an attachment, but it could not be retrieved**", - color=Color.red() - ) - await send_webhook( - webhook=self.webhook, - embed=e, - username=message.author.display_name, - avatar_url=message.author.avatar_url - ) - except discord.HTTPException: - log.exception("Failed to send an attachment to the webhook") - - await message.add_reaction("✅") - - @staticmethod - def _payload_has_duckpond_emoji(payload: RawReactionActionEvent) -> bool: - """Test if the RawReactionActionEvent payload contains a duckpond emoji.""" - if payload.emoji.is_custom_emoji(): - if payload.emoji.id in constants.DuckPond.custom_emojis: - return True - elif payload.emoji.name == "🦆": - return True - - return False - - @Cog.listener() - async def on_raw_reaction_add(self, payload: RawReactionActionEvent) -> None: - """ - Determine if a message should be sent to the duck pond. - - This will count the number of duck reactions on the message, and if this amount meets the - amount of ducks specified in the config under duck_pond/threshold, it will - send the message off to the duck pond. - """ - # Is the emoji in the reaction a duck? - if not self._payload_has_duckpond_emoji(payload): - return - - channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id) - message = await channel.fetch_message(payload.message_id) - member = discord.utils.get(message.guild.members, id=payload.user_id) - - # Is the member a human and a staff member? - if not self.is_staff(member) or member.bot: - return - - # Does the message already have a green checkmark? - if await self.has_green_checkmark(message): - return - - # Time to count our ducks! - duck_count = await self.count_ducks(message) - - # If we've got more than the required amount of ducks, send the message to the duck_pond. - if duck_count >= constants.DuckPond.threshold: - await self.relay_message(message) - - @Cog.listener() - async def on_raw_reaction_remove(self, payload: RawReactionActionEvent) -> None: - """Ensure that people don't remove the green checkmark from duck ponded messages.""" - channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id) - - # Prevent the green checkmark from being removed - if payload.emoji.name == "✅": - message = await channel.fetch_message(payload.message_id) - duck_count = await self.count_ducks(message) - if duck_count >= constants.DuckPond.threshold: - await message.add_reaction("✅") - - -def setup(bot: Bot) -> None: - """Load the DuckPond cog.""" - bot.add_cog(DuckPond(bot)) diff --git a/bot/cogs/filters/__init__.py b/bot/cogs/filters/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/bot/cogs/filters/antimalware.py b/bot/cogs/filters/antimalware.py deleted file mode 100644 index c76bd2c60..000000000 --- a/bot/cogs/filters/antimalware.py +++ /dev/null @@ -1,98 +0,0 @@ -import logging -import typing as t -from os.path import splitext - -from discord import Embed, Message, NotFound -from discord.ext.commands import Cog - -from bot.bot import Bot -from bot.constants import Channels, STAFF_ROLES, URLs - -log = logging.getLogger(__name__) - -PY_EMBED_DESCRIPTION = ( - "It looks like you tried to attach a Python file - " - f"please use a code-pasting service such as {URLs.site_schema}{URLs.site_paste}" -) - -TXT_EMBED_DESCRIPTION = ( - "**Uh-oh!** It looks like your message got zapped by our spam filter. " - "We currently don't allow `.txt` attachments, so here are some tips to help you travel safely: \n\n" - "• If you attempted to send a message longer than 2000 characters, try shortening your message " - "to fit within the character limit or use a pasting service (see below) \n\n" - "• If you tried to show someone your code, you can use codeblocks \n(run `!code-blocks` in " - "{cmd_channel_mention} for more information) or use a pasting service like: " - f"\n\n{URLs.site_schema}{URLs.site_paste}" -) - -DISALLOWED_EMBED_DESCRIPTION = ( - "It looks like you tried to attach file type(s) that we do not allow ({blocked_extensions_str}). " - "We currently allow the following file types: **{joined_whitelist}**.\n\n" - "Feel free to ask in {meta_channel_mention} if you think this is a mistake." -) - - -class AntiMalware(Cog): - """Delete messages which contain attachments with non-whitelisted file extensions.""" - - def __init__(self, bot: Bot): - self.bot = bot - - def _get_whitelisted_file_formats(self) -> list: - """Get the file formats currently on the whitelist.""" - return self.bot.filter_list_cache['FILE_FORMAT.True'].keys() - - def _get_disallowed_extensions(self, message: Message) -> t.Iterable[str]: - """Get an iterable containing all the disallowed extensions of attachments.""" - file_extensions = {splitext(attachment.filename.lower())[1] for attachment in message.attachments} - extensions_blocked = file_extensions - set(self._get_whitelisted_file_formats()) - return extensions_blocked - - @Cog.listener() - async def on_message(self, message: Message) -> None: - """Identify messages with prohibited attachments.""" - # Return when message don't have attachment and don't moderate DMs - if not message.attachments or not message.guild: - return - - # Check if user is staff, if is, return - # Since we only care that roles exist to iterate over, check for the attr rather than a User/Member instance - if hasattr(message.author, "roles") and any(role.id in STAFF_ROLES for role in message.author.roles): - return - - embed = Embed() - extensions_blocked = self._get_disallowed_extensions(message) - blocked_extensions_str = ', '.join(extensions_blocked) - if ".py" in extensions_blocked: - # Short-circuit on *.py files to provide a pastebin link - embed.description = PY_EMBED_DESCRIPTION - elif ".txt" in extensions_blocked: - # Work around Discord AutoConversion of messages longer than 2000 chars to .txt - cmd_channel = self.bot.get_channel(Channels.bot_commands) - embed.description = TXT_EMBED_DESCRIPTION.format(cmd_channel_mention=cmd_channel.mention) - elif extensions_blocked: - meta_channel = self.bot.get_channel(Channels.meta) - embed.description = DISALLOWED_EMBED_DESCRIPTION.format( - joined_whitelist=', '.join(self._get_whitelisted_file_formats()), - blocked_extensions_str=blocked_extensions_str, - meta_channel_mention=meta_channel.mention, - ) - - if embed.description: - log.info( - f"User '{message.author}' ({message.author.id}) uploaded blacklisted file(s): {blocked_extensions_str}", - extra={"attachment_list": [attachment.filename for attachment in message.attachments]} - ) - - await message.channel.send(f"Hey {message.author.mention}!", embed=embed) - - # Delete the offending message: - try: - await message.delete() - except NotFound: - log.info(f"Tried to delete message `{message.id}`, but message could not be found.") - - -def setup(bot: Bot) -> None: - """Load the AntiMalware cog.""" - bot.add_cog(AntiMalware(bot)) diff --git a/bot/cogs/filters/antispam.py b/bot/cogs/filters/antispam.py deleted file mode 100644 index d2dccea06..000000000 --- a/bot/cogs/filters/antispam.py +++ /dev/null @@ -1,288 +0,0 @@ -import asyncio -import logging -from collections.abc import Mapping -from dataclasses import dataclass, field -from datetime import datetime, timedelta -from operator import itemgetter -from typing import Dict, Iterable, List, Set - -from discord import Colour, Member, Message, NotFound, Object, TextChannel -from discord.ext.commands import Cog - -from bot import rules -from bot.bot import Bot -from bot.cogs.moderation.modlog import ModLog -from bot.constants import ( - AntiSpam as AntiSpamConfig, Channels, - Colours, DEBUG_MODE, Event, Filter, - Guild as GuildConfig, Icons, - STAFF_ROLES, -) -from bot.converters import Duration -from bot.utils.messages import send_attachments - - -log = logging.getLogger(__name__) - -RULE_FUNCTION_MAPPING = { - 'attachments': rules.apply_attachments, - 'burst': rules.apply_burst, - 'burst_shared': rules.apply_burst_shared, - 'chars': rules.apply_chars, - 'discord_emojis': rules.apply_discord_emojis, - 'duplicates': rules.apply_duplicates, - 'links': rules.apply_links, - 'mentions': rules.apply_mentions, - 'newlines': rules.apply_newlines, - 'role_mentions': rules.apply_role_mentions -} - - -@dataclass -class DeletionContext: - """Represents a Deletion Context for a single spam event.""" - - channel: TextChannel - members: Dict[int, Member] = field(default_factory=dict) - rules: Set[str] = field(default_factory=set) - messages: Dict[int, Message] = field(default_factory=dict) - attachments: List[List[str]] = field(default_factory=list) - - async def add(self, rule_name: str, members: Iterable[Member], messages: Iterable[Message]) -> None: - """Adds new rule violation events to the deletion context.""" - self.rules.add(rule_name) - - for member in members: - if member.id not in self.members: - self.members[member.id] = member - - for message in messages: - if message.id not in self.messages: - self.messages[message.id] = message - - # Re-upload attachments - destination = message.guild.get_channel(Channels.attachment_log) - urls = await send_attachments(message, destination, link_large=False) - self.attachments.append(urls) - - async def upload_messages(self, actor_id: int, modlog: ModLog) -> None: - """Method that takes care of uploading the queue and posting modlog alert.""" - triggered_by_users = ", ".join(f"{m} (`{m.id}`)" for m in self.members.values()) - - mod_alert_message = ( - f"**Triggered by:** {triggered_by_users}\n" - f"**Channel:** {self.channel.mention}\n" - f"**Rules:** {', '.join(rule for rule in self.rules)}\n" - ) - - # For multiple messages or those with excessive newlines, use the logs API - if len(self.messages) > 1 or 'newlines' in self.rules: - url = await modlog.upload_log(self.messages.values(), actor_id, self.attachments) - mod_alert_message += f"A complete log of the offending messages can be found [here]({url})" - else: - mod_alert_message += "Message:\n" - [message] = self.messages.values() - content = message.clean_content - remaining_chars = 2040 - len(mod_alert_message) - - if len(content) > remaining_chars: - content = content[:remaining_chars] + "..." - - mod_alert_message += f"{content}" - - *_, last_message = self.messages.values() - await modlog.send_log_message( - icon_url=Icons.filtering, - colour=Colour(Colours.soft_red), - title="Spam detected!", - text=mod_alert_message, - thumbnail=last_message.author.avatar_url_as(static_format="png"), - channel_id=Channels.mod_alerts, - ping_everyone=AntiSpamConfig.ping_everyone - ) - - -class AntiSpam(Cog): - """Cog that controls our anti-spam measures.""" - - def __init__(self, bot: Bot, validation_errors: Dict[str, str]) -> None: - self.bot = bot - self.validation_errors = validation_errors - role_id = AntiSpamConfig.punishment['role_id'] - self.muted_role = Object(role_id) - self.expiration_date_converter = Duration() - - self.message_deletion_queue = dict() - - self.bot.loop.create_task(self.alert_on_validation_error()) - - @property - def mod_log(self) -> ModLog: - """Allows for easy access of the ModLog cog.""" - return self.bot.get_cog("ModLog") - - async def alert_on_validation_error(self) -> None: - """Unloads the cog and alerts admins if configuration validation failed.""" - await self.bot.wait_until_guild_available() - if self.validation_errors: - body = "**The following errors were encountered:**\n" - body += "\n".join(f"- {error}" for error in self.validation_errors.values()) - body += "\n\n**The cog has been unloaded.**" - - await self.mod_log.send_log_message( - title="Error: AntiSpam configuration validation failed!", - text=body, - ping_everyone=True, - icon_url=Icons.token_removed, - colour=Colour.red() - ) - - self.bot.remove_cog(self.__class__.__name__) - return - - @Cog.listener() - async def on_message(self, message: Message) -> None: - """Applies the antispam rules to each received message.""" - if ( - not message.guild - or message.guild.id != GuildConfig.id - or message.author.bot - or (message.channel.id in Filter.channel_whitelist and not DEBUG_MODE) - or (any(role.id in STAFF_ROLES for role in message.author.roles) and not DEBUG_MODE) - ): - return - - # Fetch the rule configuration with the highest rule interval. - max_interval_config = max( - AntiSpamConfig.rules.values(), - key=itemgetter('interval') - ) - max_interval = max_interval_config['interval'] - - # Store history messages since `interval` seconds ago in a list to prevent unnecessary API calls. - earliest_relevant_at = datetime.utcnow() - timedelta(seconds=max_interval) - relevant_messages = [ - msg async for msg in message.channel.history(after=earliest_relevant_at, oldest_first=False) - if not msg.author.bot - ] - - for rule_name in AntiSpamConfig.rules: - rule_config = AntiSpamConfig.rules[rule_name] - rule_function = RULE_FUNCTION_MAPPING[rule_name] - - # Create a list of messages that were sent in the interval that the rule cares about. - latest_interesting_stamp = datetime.utcnow() - timedelta(seconds=rule_config['interval']) - messages_for_rule = [ - msg for msg in relevant_messages if msg.created_at > latest_interesting_stamp - ] - result = await rule_function(message, messages_for_rule, rule_config) - - # If the rule returns `None`, that means the message didn't violate it. - # If it doesn't, it returns a tuple in the form `(str, Iterable[discord.Member])` - # which contains the reason for why the message violated the rule and - # an iterable of all members that violated the rule. - if result is not None: - self.bot.stats.incr(f"mod_alerts.{rule_name}") - reason, members, relevant_messages = result - full_reason = f"`{rule_name}` rule: {reason}" - - # If there's no spam event going on for this channel, start a new Message Deletion Context - channel = message.channel - if channel.id not in self.message_deletion_queue: - log.trace(f"Creating queue for channel `{channel.id}`") - self.message_deletion_queue[message.channel.id] = DeletionContext(channel) - self.bot.loop.create_task(self._process_deletion_context(message.channel.id)) - - # Add the relevant of this trigger to the Deletion Context - await self.message_deletion_queue[message.channel.id].add( - rule_name=rule_name, - members=members, - messages=relevant_messages - ) - - for member in members: - - # Fire it off as a background task to ensure - # that the sleep doesn't block further tasks - self.bot.loop.create_task( - self.punish(message, member, full_reason) - ) - - await self.maybe_delete_messages(channel, relevant_messages) - break - - async def punish(self, msg: Message, member: Member, reason: str) -> None: - """Punishes the given member for triggering an antispam rule.""" - if not any(role.id == self.muted_role.id for role in member.roles): - remove_role_after = AntiSpamConfig.punishment['remove_after'] - - # Get context and make sure the bot becomes the actor of infraction by patching the `author` attributes - context = await self.bot.get_context(msg) - context.author = self.bot.user - context.message.author = self.bot.user - - # Since we're going to invoke the tempmute command directly, we need to manually call the converter. - dt_remove_role_after = await self.expiration_date_converter.convert(context, f"{remove_role_after}S") - await context.invoke( - self.bot.get_command('tempmute'), - member, - dt_remove_role_after, - reason=reason - ) - - async def maybe_delete_messages(self, channel: TextChannel, messages: List[Message]) -> None: - """Cleans the messages if cleaning is configured.""" - if AntiSpamConfig.clean_offending: - # If we have more than one message, we can use bulk delete. - if len(messages) > 1: - message_ids = [message.id for message in messages] - self.mod_log.ignore(Event.message_delete, *message_ids) - await channel.delete_messages(messages) - - # Otherwise, the bulk delete endpoint will throw up. - # Delete the message directly instead. - else: - self.mod_log.ignore(Event.message_delete, messages[0].id) - try: - await messages[0].delete() - except NotFound: - log.info(f"Tried to delete message `{messages[0].id}`, but message could not be found.") - - async def _process_deletion_context(self, context_id: int) -> None: - """Processes the Deletion Context queue.""" - log.trace("Sleeping before processing message deletion queue.") - await asyncio.sleep(10) - - if context_id not in self.message_deletion_queue: - log.error(f"Started processing deletion queue for context `{context_id}`, but it was not found!") - return - - deletion_context = self.message_deletion_queue.pop(context_id) - await deletion_context.upload_messages(self.bot.user.id, self.mod_log) - - -def validate_config(rules_: Mapping = AntiSpamConfig.rules) -> Dict[str, str]: - """Validates the antispam configs.""" - validation_errors = {} - for name, config in rules_.items(): - if name not in RULE_FUNCTION_MAPPING: - log.error( - f"Unrecognized antispam rule `{name}`. " - f"Valid rules are: {', '.join(RULE_FUNCTION_MAPPING)}" - ) - validation_errors[name] = f"`{name}` is not recognized as an antispam rule." - continue - for required_key in ('interval', 'max'): - if required_key not in config: - log.error( - f"`{required_key}` is required but was not " - f"set in rule `{name}`'s configuration." - ) - validation_errors[name] = f"Key `{required_key}` is required but not set for rule `{name}`" - return validation_errors - - -def setup(bot: Bot) -> None: - """Validate the AntiSpam configs and load the AntiSpam cog.""" - validation_errors = validate_config() - bot.add_cog(AntiSpam(bot, validation_errors)) diff --git a/bot/cogs/filters/filter_lists.py b/bot/cogs/filters/filter_lists.py deleted file mode 100644 index c15adc461..000000000 --- a/bot/cogs/filters/filter_lists.py +++ /dev/null @@ -1,273 +0,0 @@ -import logging -from typing import Optional - -from discord import Colour, Embed -from discord.ext.commands import BadArgument, Cog, Context, IDConverter, group - -from bot import constants -from bot.api import ResponseCodeError -from bot.bot import Bot -from bot.converters import ValidDiscordServerInvite, ValidFilterListType -from bot.pagination import LinePaginator -from bot.utils.checks import with_role_check - -log = logging.getLogger(__name__) - - -class FilterLists(Cog): - """Commands for blacklisting and whitelisting things.""" - - methods_with_filterlist_types = [ - "allow_add", - "allow_delete", - "allow_get", - "deny_add", - "deny_delete", - "deny_get", - ] - - def __init__(self, bot: Bot) -> None: - self.bot = bot - self.bot.loop.create_task(self._amend_docstrings()) - - async def _amend_docstrings(self) -> None: - """Add the valid FilterList types to the docstrings, so they'll appear in !help invocations.""" - await self.bot.wait_until_guild_available() - - # Add valid filterlist types to the docstrings - valid_types = await ValidFilterListType.get_valid_types(self.bot) - valid_types = [f"`{type_.lower()}`" for type_ in valid_types] - - for method_name in self.methods_with_filterlist_types: - command = getattr(self, method_name) - command.help = ( - f"{command.help}\n\nValid **list_type** values are {', '.join(valid_types)}." - ) - - async def _add_data( - self, - ctx: Context, - allowed: bool, - list_type: ValidFilterListType, - content: str, - comment: Optional[str] = None, - ) -> None: - """Add an item to a filterlist.""" - allow_type = "whitelist" if allowed else "blacklist" - - # If this is a server invite, we gotta validate it. - if list_type == "GUILD_INVITE": - guild_data = await self._validate_guild_invite(ctx, content) - content = guild_data.get("id") - - # Unless the user has specified another comment, let's - # use the server name as the comment so that the list - # of guild IDs will be more easily readable when we - # display it. - if not comment: - comment = guild_data.get("name") - - # If it's a file format, let's make sure it has a leading dot. - elif list_type == "FILE_FORMAT" and not content.startswith("."): - content = f".{content}" - - # Try to add the item to the database - log.trace(f"Trying to add the {content} item to the {list_type} {allow_type}") - payload = { - "allowed": allowed, - "type": list_type, - "content": content, - "comment": comment, - } - - try: - item = await self.bot.api_client.post( - "bot/filter-lists", - json=payload - ) - except ResponseCodeError as e: - if e.status == 400: - await ctx.message.add_reaction("❌") - log.debug( - f"{ctx.author} tried to add data to a {allow_type}, but the API returned 400, " - "probably because the request violated the UniqueConstraint." - ) - raise BadArgument( - f"Unable to add the item to the {allow_type}. " - "The item probably already exists. Keep in mind that a " - "blacklist and a whitelist for the same item cannot co-exist, " - "and we do not permit any duplicates." - ) - raise - - # Insert the item into the cache - self.bot.insert_item_into_filter_list_cache(item) - await ctx.message.add_reaction("✅") - - async def _delete_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType, content: str) -> None: - """Remove an item from a filterlist.""" - allow_type = "whitelist" if allowed else "blacklist" - - # If this is a server invite, we need to convert it. - if list_type == "GUILD_INVITE" and not IDConverter()._get_id_match(content): - guild_data = await self._validate_guild_invite(ctx, content) - content = guild_data.get("id") - - # If it's a file format, let's make sure it has a leading dot. - elif list_type == "FILE_FORMAT" and not content.startswith("."): - content = f".{content}" - - # Find the content and delete it. - log.trace(f"Trying to delete the {content} item from the {list_type} {allow_type}") - item = self.bot.filter_list_cache[f"{list_type}.{allowed}"].get(content) - - if item is not None: - try: - await self.bot.api_client.delete( - f"bot/filter-lists/{item['id']}" - ) - del self.bot.filter_list_cache[f"{list_type}.{allowed}"][content] - await ctx.message.add_reaction("✅") - except ResponseCodeError as e: - log.debug( - f"{ctx.author} tried to delete an item with the id {item['id']}, but " - f"the API raised an unexpected error: {e}" - ) - await ctx.message.add_reaction("❌") - else: - await ctx.message.add_reaction("❌") - - async def _list_all_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType) -> None: - """Paginate and display all items in a filterlist.""" - allow_type = "whitelist" if allowed else "blacklist" - result = self.bot.filter_list_cache[f"{list_type}.{allowed}"] - - # Build a list of lines we want to show in the paginator - lines = [] - for content, metadata in result.items(): - line = f"• `{content}`" - - if comment := metadata.get("comment"): - line += f" - {comment}" - - lines.append(line) - lines = sorted(lines) - - # Build the embed - list_type_plural = list_type.lower().replace("_", " ").title() + "s" - embed = Embed( - title=f"{allow_type.title()}ed {list_type_plural} ({len(result)} total)", - colour=Colour.blue() - ) - log.trace(f"Trying to list {len(result)} items from the {list_type.lower()} {allow_type}") - - if result: - await LinePaginator.paginate(lines, ctx, embed, max_lines=15, empty=False) - else: - embed.description = "Hmmm, seems like there's nothing here yet." - await ctx.send(embed=embed) - await ctx.message.add_reaction("❌") - - async def _sync_data(self, ctx: Context) -> None: - """Syncs the filterlists with the API.""" - try: - log.trace("Attempting to sync FilterList cache with data from the API.") - await self.bot.cache_filter_list_data() - await ctx.message.add_reaction("✅") - except ResponseCodeError as e: - log.debug( - f"{ctx.author} tried to sync FilterList cache data but " - f"the API raised an unexpected error: {e}" - ) - await ctx.message.add_reaction("❌") - - @staticmethod - async def _validate_guild_invite(ctx: Context, invite: str) -> dict: - """ - Validates a guild invite, and returns the guild info as a dict. - - Will raise a BadArgument if the guild invite is invalid. - """ - log.trace(f"Attempting to validate whether or not {invite} is a guild invite.") - validator = ValidDiscordServerInvite() - guild_data = await validator.convert(ctx, invite) - - # If we make it this far without raising a BadArgument, the invite is - # valid. Let's return a dict of guild information. - log.trace(f"{invite} validated as server invite. Converting to ID.") - return guild_data - - @group(aliases=("allowlist", "allow", "al", "wl")) - async def whitelist(self, ctx: Context) -> None: - """Group for whitelisting commands.""" - if not ctx.invoked_subcommand: - await ctx.send_help(ctx.command) - - @group(aliases=("denylist", "deny", "bl", "dl")) - async def blacklist(self, ctx: Context) -> None: - """Group for blacklisting commands.""" - if not ctx.invoked_subcommand: - await ctx.send_help(ctx.command) - - @whitelist.command(name="add", aliases=("a", "set")) - async def allow_add( - self, - ctx: Context, - list_type: ValidFilterListType, - content: str, - *, - comment: Optional[str] = None, - ) -> None: - """Add an item to the specified allowlist.""" - await self._add_data(ctx, True, list_type, content, comment) - - @blacklist.command(name="add", aliases=("a", "set")) - async def deny_add( - self, - ctx: Context, - list_type: ValidFilterListType, - content: str, - *, - comment: Optional[str] = None, - ) -> None: - """Add an item to the specified denylist.""" - await self._add_data(ctx, False, list_type, content, comment) - - @whitelist.command(name="remove", aliases=("delete", "rm",)) - async def allow_delete(self, ctx: Context, list_type: ValidFilterListType, content: str) -> None: - """Remove an item from the specified allowlist.""" - await self._delete_data(ctx, True, list_type, content) - - @blacklist.command(name="remove", aliases=("delete", "rm",)) - async def deny_delete(self, ctx: Context, list_type: ValidFilterListType, content: str) -> None: - """Remove an item from the specified denylist.""" - await self._delete_data(ctx, False, list_type, content) - - @whitelist.command(name="get", aliases=("list", "ls", "fetch", "show")) - async def allow_get(self, ctx: Context, list_type: ValidFilterListType) -> None: - """Get the contents of a specified allowlist.""" - await self._list_all_data(ctx, True, list_type) - - @blacklist.command(name="get", aliases=("list", "ls", "fetch", "show")) - async def deny_get(self, ctx: Context, list_type: ValidFilterListType) -> None: - """Get the contents of a specified denylist.""" - await self._list_all_data(ctx, False, list_type) - - @whitelist.command(name="sync", aliases=("s",)) - async def allow_sync(self, ctx: Context) -> None: - """Syncs both allowlists and denylists with the API.""" - await self._sync_data(ctx) - - @blacklist.command(name="sync", aliases=("s",)) - async def deny_sync(self, ctx: Context) -> None: - """Syncs both allowlists and denylists with the API.""" - await self._sync_data(ctx) - - def cog_check(self, ctx: Context) -> bool: - """Only allow moderators to invoke the commands in this cog.""" - return with_role_check(ctx, *constants.MODERATION_ROLES) - - -def setup(bot: Bot) -> None: - """Load the FilterLists cog.""" - bot.add_cog(FilterLists(bot)) diff --git a/bot/cogs/filters/filtering.py b/bot/cogs/filters/filtering.py deleted file mode 100644 index 556b466ef..000000000 --- a/bot/cogs/filters/filtering.py +++ /dev/null @@ -1,575 +0,0 @@ -import asyncio -import logging -import re -from datetime import datetime, timedelta -from typing import List, Mapping, Optional, Tuple, Union - -import dateutil -import discord.errors -from dateutil.relativedelta import relativedelta -from discord import Colour, HTTPException, Member, Message, NotFound, TextChannel -from discord.ext.commands import Cog -from discord.utils import escape_markdown - -from bot.bot import Bot -from bot.cogs.moderation.modlog import ModLog -from bot.constants import ( - Channels, Colours, - Filter, Icons, URLs -) -from bot.utils.redis_cache import RedisCache -from bot.utils.regex import INVITE_RE -from bot.utils.scheduling import Scheduler - -log = logging.getLogger(__name__) - -# Regular expressions -SPOILER_RE = re.compile(r"(\|\|.+?\|\|)", re.DOTALL) -URL_RE = re.compile(r"(https?://[^\s]+)", flags=re.IGNORECASE) -ZALGO_RE = re.compile(r"[\u0300-\u036F\u0489]") - -# Other constants. -DAYS_BETWEEN_ALERTS = 3 -OFFENSIVE_MSG_DELETE_TIME = timedelta(days=Filter.offensive_msg_delete_days) - - -class Filtering(Cog): - """Filtering out invites, blacklisting domains, and warning us of certain regular expressions.""" - - # Redis cache mapping a user ID to the last timestamp a bad nickname alert was sent - name_alerts = RedisCache() - - def __init__(self, bot: Bot): - self.bot = bot - self.scheduler = Scheduler(self.__class__.__name__) - self.name_lock = asyncio.Lock() - - staff_mistake_str = "If you believe this was a mistake, please let staff know!" - self.filters = { - "filter_zalgo": { - "enabled": Filter.filter_zalgo, - "function": self._has_zalgo, - "type": "filter", - "content_only": True, - "user_notification": Filter.notify_user_zalgo, - "notification_msg": ( - "Your post has been removed for abusing Unicode character rendering (aka Zalgo text). " - f"{staff_mistake_str}" - ), - "schedule_deletion": False - }, - "filter_invites": { - "enabled": Filter.filter_invites, - "function": self._has_invites, - "type": "filter", - "content_only": True, - "user_notification": Filter.notify_user_invites, - "notification_msg": ( - f"Per Rule 6, your invite link has been removed. {staff_mistake_str}\n\n" - r"Our server rules can be found here: " - ), - "schedule_deletion": False - }, - "filter_domains": { - "enabled": Filter.filter_domains, - "function": self._has_urls, - "type": "filter", - "content_only": True, - "user_notification": Filter.notify_user_domains, - "notification_msg": ( - f"Your URL has been removed because it matched a blacklisted domain. {staff_mistake_str}" - ), - "schedule_deletion": False - }, - "watch_regex": { - "enabled": Filter.watch_regex, - "function": self._has_watch_regex_match, - "type": "watchlist", - "content_only": True, - "schedule_deletion": True - }, - "watch_rich_embeds": { - "enabled": Filter.watch_rich_embeds, - "function": self._has_rich_embed, - "type": "watchlist", - "content_only": False, - "schedule_deletion": False - } - } - - self.bot.loop.create_task(self.reschedule_offensive_msg_deletion()) - - def cog_unload(self) -> None: - """Cancel scheduled tasks.""" - self.scheduler.cancel_all() - - def _get_filterlist_items(self, list_type: str, *, allowed: bool) -> list: - """Fetch items from the filter_list_cache.""" - return self.bot.filter_list_cache[f"{list_type.upper()}.{allowed}"].keys() - - @staticmethod - def _expand_spoilers(text: str) -> str: - """Return a string containing all interpretations of a spoilered message.""" - split_text = SPOILER_RE.split(text) - return ''.join( - split_text[0::2] + split_text[1::2] + split_text - ) - - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - @Cog.listener() - async def on_message(self, msg: Message) -> None: - """Invoke message filter for new messages.""" - await self._filter_message(msg) - - # Ignore webhook messages. - if msg.webhook_id is None: - await self.check_bad_words_in_name(msg.author) - - @Cog.listener() - async def on_message_edit(self, before: Message, after: Message) -> None: - """ - Invoke message filter for message edits. - - If there have been multiple edits, calculate the time delta from the previous edit. - """ - if not before.edited_at: - delta = relativedelta(after.edited_at, before.created_at).microseconds - else: - delta = relativedelta(after.edited_at, before.edited_at).microseconds - await self._filter_message(after, delta) - - def get_name_matches(self, name: str) -> List[re.Match]: - """Check bad words from passed string (name). Return list of matches.""" - matches = [] - watchlist_patterns = self._get_filterlist_items('filter_token', allowed=False) - for pattern in watchlist_patterns: - if match := re.search(pattern, name, flags=re.IGNORECASE): - matches.append(match) - return matches - - async def check_send_alert(self, member: Member) -> bool: - """When there is less than 3 days after last alert, return `False`, otherwise `True`.""" - if last_alert := await self.name_alerts.get(member.id): - last_alert = datetime.utcfromtimestamp(last_alert) - if datetime.utcnow() - timedelta(days=DAYS_BETWEEN_ALERTS) < last_alert: - log.trace(f"Last alert was too recent for {member}'s nickname.") - return False - - return True - - async def check_bad_words_in_name(self, member: Member) -> None: - """Send a mod alert every 3 days if a username still matches a watchlist pattern.""" - # Use lock to avoid race conditions - async with self.name_lock: - # Check whether the users display name contains any words in our blacklist - matches = self.get_name_matches(member.display_name) - - if not matches or not await self.check_send_alert(member): - return - - log.info(f"Sending bad nickname alert for '{member.display_name}' ({member.id}).") - - log_string = ( - f"**User:** {member.mention} (`{member.id}`)\n" - f"**Display Name:** {member.display_name}\n" - f"**Bad Matches:** {', '.join(match.group() for match in matches)}" - ) - - await self.mod_log.send_log_message( - icon_url=Icons.token_removed, - colour=Colours.soft_red, - title="Username filtering alert", - text=log_string, - channel_id=Channels.mod_alerts, - thumbnail=member.avatar_url - ) - - # Update time when alert sent - await self.name_alerts.set(member.id, datetime.utcnow().timestamp()) - - async def filter_eval(self, result: str, msg: Message) -> bool: - """ - Filter the result of an !eval to see if it violates any of our rules, and then respond accordingly. - - Also requires the original message, to check whether to filter and for mod logs. - Returns whether a filter was triggered or not. - """ - filter_triggered = False - # Should we filter this message? - if self._check_filter(msg): - for filter_name, _filter in self.filters.items(): - # Is this specific filter enabled in the config? - # We also do not need to worry about filters that take the full message, - # since all we have is an arbitrary string. - if _filter["enabled"] and _filter["content_only"]: - match = await _filter["function"](result) - - if match: - # If this is a filter (not a watchlist), we set the variable so we know - # that it has been triggered - if _filter["type"] == "filter": - filter_triggered = True - - # We do not have to check against DM channels since !eval cannot be used there. - channel_str = f"in {msg.channel.mention}" - - message_content, additional_embeds, additional_embeds_msg = self._add_stats( - filter_name, match, result - ) - - message = ( - f"The {filter_name} {_filter['type']} was triggered " - f"by **{msg.author}** " - f"(`{msg.author.id}`) {channel_str} using !eval with " - f"[the following message]({msg.jump_url}):\n\n" - f"{message_content}" - ) - - log.debug(message) - - # Send pretty mod log embed to mod-alerts - await self.mod_log.send_log_message( - icon_url=Icons.filtering, - colour=Colour(Colours.soft_red), - title=f"{_filter['type'].title()} triggered!", - text=message, - thumbnail=msg.author.avatar_url_as(static_format="png"), - channel_id=Channels.mod_alerts, - ping_everyone=Filter.ping_everyone, - additional_embeds=additional_embeds, - additional_embeds_msg=additional_embeds_msg - ) - - break # We don't want multiple filters to trigger - - return filter_triggered - - async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> None: - """Filter the input message to see if it violates any of our rules, and then respond accordingly.""" - # Should we filter this message? - if self._check_filter(msg): - for filter_name, _filter in self.filters.items(): - # Is this specific filter enabled in the config? - if _filter["enabled"]: - # Double trigger check for the embeds filter - if filter_name == "watch_rich_embeds": - # If the edit delta is less than 0.001 seconds, then we're probably dealing - # with a double filter trigger. - if delta is not None and delta < 100: - continue - - # Does the filter only need the message content or the full message? - if _filter["content_only"]: - match = await _filter["function"](msg.content) - else: - match = await _filter["function"](msg) - - if match: - is_private = msg.channel.type is discord.ChannelType.private - - # If this is a filter (not a watchlist) and not in a DM, delete the message. - if _filter["type"] == "filter" and not is_private: - try: - # Embeds (can?) trigger both the `on_message` and `on_message_edit` - # event handlers, triggering filtering twice for the same message. - # - # If `on_message`-triggered filtering already deleted the message - # then `on_message_edit`-triggered filtering will raise exception - # since the message no longer exists. - # - # In addition, to avoid sending two notifications to the user, the - # logs, and mod_alert, we return if the message no longer exists. - await msg.delete() - except discord.errors.NotFound: - return - - # Notify the user if the filter specifies - if _filter["user_notification"]: - await self.notify_member(msg.author, _filter["notification_msg"], msg.channel) - - # If the message is classed as offensive, we store it in the site db and - # it will be deleted it after one week. - if _filter["schedule_deletion"] and not is_private: - delete_date = (msg.created_at + OFFENSIVE_MSG_DELETE_TIME).isoformat() - data = { - 'id': msg.id, - 'channel_id': msg.channel.id, - 'delete_date': delete_date - } - - await self.bot.api_client.post('bot/offensive-messages', json=data) - self.schedule_msg_delete(data) - log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}") - - if is_private: - channel_str = "via DM" - else: - channel_str = f"in {msg.channel.mention}" - - message_content, additional_embeds, additional_embeds_msg = self._add_stats( - filter_name, match, msg.content - ) - - message = ( - f"The {filter_name} {_filter['type']} was triggered " - f"by **{msg.author}** " - f"(`{msg.author.id}`) {channel_str} with [the " - f"following message]({msg.jump_url}):\n\n" - f"{message_content}" - ) - - log.debug(message) - - # Send pretty mod log embed to mod-alerts - await self.mod_log.send_log_message( - icon_url=Icons.filtering, - colour=Colour(Colours.soft_red), - title=f"{_filter['type'].title()} triggered!", - text=message, - thumbnail=msg.author.avatar_url_as(static_format="png"), - channel_id=Channels.mod_alerts, - ping_everyone=Filter.ping_everyone if not is_private else False, - additional_embeds=additional_embeds, - additional_embeds_msg=additional_embeds_msg - ) - - break # We don't want multiple filters to trigger - - def _add_stats(self, name: str, match: Union[re.Match, dict, bool, List[discord.Embed]], content: str) -> Tuple[ - str, Optional[List[discord.Embed]], Optional[str] - ]: - """Adds relevant statistical information to the relevant filter and increments the bot's stats.""" - # Word and match stats for watch_regex - if name == "watch_regex": - surroundings = match.string[max(match.start() - 10, 0): match.end() + 10] - message_content = ( - f"**Match:** '{match[0]}'\n" - f"**Location:** '...{escape_markdown(surroundings)}...'\n" - f"\n**Original Message:**\n{escape_markdown(content)}" - ) - else: # Use original content - message_content = content - - additional_embeds = None - additional_embeds_msg = None - - self.bot.stats.incr(f"filters.{name}") - - # The function returns True for invalid invites. - # They have no data so additional embeds can't be created for them. - if name == "filter_invites" and match is not True: - additional_embeds = [] - for _, data in match.items(): - embed = discord.Embed(description=( - f"**Members:**\n{data['members']}\n" - f"**Active:**\n{data['active']}" - )) - embed.set_author(name=data["name"]) - embed.set_thumbnail(url=data["icon"]) - embed.set_footer(text=f"Guild ID: {data['id']}") - additional_embeds.append(embed) - additional_embeds_msg = "For the following guild(s):" - - elif name == "watch_rich_embeds": - additional_embeds = match - additional_embeds_msg = "With the following embed(s):" - - return message_content, additional_embeds, additional_embeds_msg - - @staticmethod - def _check_filter(msg: Message) -> bool: - """Check whitelists to see if we should filter this message.""" - role_whitelisted = False - - if type(msg.author) is Member: # Only Member has roles, not User. - for role in msg.author.roles: - if role.id in Filter.role_whitelist: - role_whitelisted = True - - return ( - msg.channel.id not in Filter.channel_whitelist # Channel not in whitelist - and not role_whitelisted # Role not in whitelist - and not msg.author.bot # Author not a bot - ) - - async def _has_watch_regex_match(self, text: str) -> Union[bool, re.Match]: - """ - Return True if `text` matches any regex from `word_watchlist` or `token_watchlist` configs. - - `word_watchlist`'s patterns are placed between word boundaries while `token_watchlist` is - matched as-is. Spoilers are expanded, if any, and URLs are ignored. - """ - if SPOILER_RE.search(text): - text = self._expand_spoilers(text) - - # Make sure it's not a URL - if URL_RE.search(text): - return False - - watchlist_patterns = self._get_filterlist_items('filter_token', allowed=False) - for pattern in watchlist_patterns: - match = re.search(pattern, text, flags=re.IGNORECASE) - if match: - return match - - async def _has_urls(self, text: str) -> bool: - """Returns True if the text contains one of the blacklisted URLs from the config file.""" - if not URL_RE.search(text): - return False - - text = text.lower() - domain_blacklist = self._get_filterlist_items("domain_name", allowed=False) - - for url in domain_blacklist: - if url.lower() in text: - return True - - return False - - @staticmethod - async def _has_zalgo(text: str) -> bool: - """ - Returns True if the text contains zalgo characters. - - Zalgo range is \u0300 – \u036F and \u0489. - """ - return bool(ZALGO_RE.search(text)) - - async def _has_invites(self, text: str) -> Union[dict, bool]: - """ - Checks if there's any invites in the text content that aren't in the guild whitelist. - - If any are detected, a dictionary of invite data is returned, with a key per invite. - If none are detected, False is returned. - - Attempts to catch some of common ways to try to cheat the system. - """ - # Remove backslashes to prevent escape character aroundfuckery like - # discord\.gg/gdudes-pony-farm - text = text.replace("\\", "") - - invites = INVITE_RE.findall(text) - invite_data = dict() - for invite in invites: - if invite in invite_data: - continue - - response = await self.bot.http_session.get( - f"{URLs.discord_invite_api}/{invite}", params={"with_counts": "true"} - ) - response = await response.json() - guild = response.get("guild") - if guild is None: - # Lack of a "guild" key in the JSON response indicates either an group DM invite, an - # expired invite, or an invalid invite. The API does not currently differentiate - # between invalid and expired invites - return True - - guild_id = guild.get("id") - guild_invite_whitelist = self._get_filterlist_items("guild_invite", allowed=True) - guild_invite_blacklist = self._get_filterlist_items("guild_invite", allowed=False) - - # Is this invite allowed? - guild_partnered_or_verified = ( - 'PARTNERED' in guild.get("features", []) - or 'VERIFIED' in guild.get("features", []) - ) - invite_not_allowed = ( - guild_id in guild_invite_blacklist # Blacklisted guilds are never permitted. - or guild_id not in guild_invite_whitelist # Whitelisted guilds are always permitted. - and not guild_partnered_or_verified # Otherwise guilds have to be Verified or Partnered. - ) - - if invite_not_allowed: - guild_icon_hash = guild["icon"] - guild_icon = ( - "https://cdn.discordapp.com/icons/" - f"{guild_id}/{guild_icon_hash}.png?size=512" - ) - - invite_data[invite] = { - "name": guild["name"], - "id": guild['id'], - "icon": guild_icon, - "members": response["approximate_member_count"], - "active": response["approximate_presence_count"] - } - - return invite_data if invite_data else False - - @staticmethod - async def _has_rich_embed(msg: Message) -> Union[bool, List[discord.Embed]]: - """Determines if `msg` contains any rich embeds not auto-generated from a URL.""" - if msg.embeds: - for embed in msg.embeds: - if embed.type == "rich": - urls = URL_RE.findall(msg.content) - if not embed.url or embed.url not in urls: - # If `embed.url` does not exist or if `embed.url` is not part of the content - # of the message, it's unlikely to be an auto-generated embed by Discord. - return msg.embeds - else: - log.trace( - "Found a rich embed sent by a regular user account, " - "but it was likely just an automatic URL embed." - ) - return False - return False - - async def notify_member(self, filtered_member: Member, reason: str, channel: TextChannel) -> None: - """ - Notify filtered_member about a moderation action with the reason str. - - First attempts to DM the user, fall back to in-channel notification if user has DMs disabled - """ - try: - await filtered_member.send(reason) - except discord.errors.Forbidden: - await channel.send(f"{filtered_member.mention} {reason}") - - def schedule_msg_delete(self, msg: dict) -> None: - """Delete an offensive message once its deletion date is reached.""" - delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None) - self.scheduler.schedule_at(delete_at, msg['id'], self.delete_offensive_msg(msg)) - - async def reschedule_offensive_msg_deletion(self) -> None: - """Get all the pending message deletion from the API and reschedule them.""" - await self.bot.wait_until_ready() - response = await self.bot.api_client.get('bot/offensive-messages',) - - now = datetime.utcnow() - - for msg in response: - delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None) - - if delete_at < now: - await self.delete_offensive_msg(msg) - else: - self.schedule_msg_delete(msg) - - async def delete_offensive_msg(self, msg: Mapping[str, str]) -> None: - """Delete an offensive message, and then delete it from the db.""" - try: - channel = self.bot.get_channel(msg['channel_id']) - if channel: - msg_obj = await channel.fetch_message(msg['id']) - await msg_obj.delete() - except NotFound: - log.info( - f"Tried to delete message {msg['id']}, but the message can't be found " - f"(it has been probably already deleted)." - ) - except HTTPException as e: - log.warning(f"Failed to delete message {msg['id']}: status {e.status}") - - await self.bot.api_client.delete(f'bot/offensive-messages/{msg["id"]}') - log.info(f"Deleted the offensive message with id {msg['id']}.") - - -def setup(bot: Bot) -> None: - """Load the Filtering cog.""" - bot.add_cog(Filtering(bot)) diff --git a/bot/cogs/filters/security.py b/bot/cogs/filters/security.py deleted file mode 100644 index c680c5e27..000000000 --- a/bot/cogs/filters/security.py +++ /dev/null @@ -1,31 +0,0 @@ -import logging - -from discord.ext.commands import Cog, Context, NoPrivateMessage - -from bot.bot import Bot - -log = logging.getLogger(__name__) - - -class Security(Cog): - """Security-related helpers.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.bot.check(self.check_not_bot) # Global commands check - no bots can run any commands at all - self.bot.check(self.check_on_guild) # Global commands check - commands can't be run in a DM - - def check_not_bot(self, ctx: Context) -> bool: - """Check if the context is a bot user.""" - return not ctx.author.bot - - def check_on_guild(self, ctx: Context) -> bool: - """Check if the context is in a guild.""" - if ctx.guild is None: - raise NoPrivateMessage("This command cannot be used in private messages.") - return True - - -def setup(bot: Bot) -> None: - """Load the Security cog.""" - bot.add_cog(Security(bot)) diff --git a/bot/cogs/filters/token_remover.py b/bot/cogs/filters/token_remover.py deleted file mode 100644 index 8eace07b6..000000000 --- a/bot/cogs/filters/token_remover.py +++ /dev/null @@ -1,182 +0,0 @@ -import base64 -import binascii -import logging -import re -import typing as t - -from discord import Colour, Message, NotFound -from discord.ext.commands import Cog - -from bot import utils -from bot.bot import Bot -from bot.cogs.moderation.modlog import ModLog -from bot.constants import Channels, Colours, Event, Icons - -log = logging.getLogger(__name__) - -LOG_MESSAGE = ( - "Censored a seemingly valid token sent by {author} (`{author_id}`) in {channel}, " - "token was `{user_id}.{timestamp}.{hmac}`" -) -DELETION_MESSAGE_TEMPLATE = ( - "Hey {mention}! I noticed you posted a seemingly valid Discord API " - "token in your message and have removed your message. " - "This means that your token has been **compromised**. " - "Please change your token **immediately** at: " - "\n\n" - "Feel free to re-post it with the token removed. " - "If you believe this was a mistake, please let us know!" -) -DISCORD_EPOCH = 1_420_070_400 -TOKEN_EPOCH = 1_293_840_000 - -# Three parts delimited by dots: user ID, creation timestamp, HMAC. -# The HMAC isn't parsed further, but it's in the regex to ensure it at least exists in the string. -# Each part only matches base64 URL-safe characters. -# Padding has never been observed, but the padding character '=' is matched just in case. -TOKEN_RE = re.compile(r"([\w\-=]+)\.([\w\-=]+)\.([\w\-=]+)", re.ASCII) - - -class Token(t.NamedTuple): - """A Discord Bot token.""" - - user_id: str - timestamp: str - hmac: str - - -class TokenRemover(Cog): - """Scans messages for potential discord.py bot tokens and removes them.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - @Cog.listener() - async def on_message(self, msg: Message) -> None: - """ - Check each message for a string that matches Discord's token pattern. - - See: https://discordapp.com/developers/docs/reference#snowflakes - """ - # Ignore DMs; can't delete messages in there anyway. - if not msg.guild or msg.author.bot: - return - - found_token = self.find_token_in_message(msg) - if found_token: - await self.take_action(msg, found_token) - - @Cog.listener() - async def on_message_edit(self, before: Message, after: Message) -> None: - """ - Check each edit for a string that matches Discord's token pattern. - - See: https://discordapp.com/developers/docs/reference#snowflakes - """ - await self.on_message(after) - - async def take_action(self, msg: Message, found_token: Token) -> None: - """Remove the `msg` containing the `found_token` and send a mod log message.""" - self.mod_log.ignore(Event.message_delete, msg.id) - - try: - await msg.delete() - except NotFound: - log.debug(f"Failed to remove token in message {msg.id}: message already deleted.") - return - - await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention)) - - log_message = self.format_log_message(msg, found_token) - log.debug(log_message) - - # Send pretty mod log embed to mod-alerts - await self.mod_log.send_log_message( - icon_url=Icons.token_removed, - colour=Colour(Colours.soft_red), - title="Token removed!", - text=log_message, - thumbnail=msg.author.avatar_url_as(static_format="png"), - channel_id=Channels.mod_alerts, - ) - - self.bot.stats.incr("tokens.removed_tokens") - - @staticmethod - def format_log_message(msg: Message, token: Token) -> str: - """Return the log message to send for `token` being censored in `msg`.""" - return LOG_MESSAGE.format( - author=msg.author, - author_id=msg.author.id, - channel=msg.channel.mention, - user_id=token.user_id, - timestamp=token.timestamp, - hmac='x' * len(token.hmac), - ) - - @classmethod - def find_token_in_message(cls, msg: Message) -> t.Optional[Token]: - """Return a seemingly valid token found in `msg` or `None` if no token is found.""" - # Use finditer rather than search to guard against method calls prematurely returning the - # token check (e.g. `message.channel.send` also matches our token pattern) - for match in TOKEN_RE.finditer(msg.content): - token = Token(*match.groups()) - if cls.is_valid_user_id(token.user_id) and cls.is_valid_timestamp(token.timestamp): - # Short-circuit on first match - return token - - # No matching substring - return - - @staticmethod - def is_valid_user_id(b64_content: str) -> bool: - """ - Check potential token to see if it contains a valid Discord user ID. - - See: https://discordapp.com/developers/docs/reference#snowflakes - """ - b64_content = utils.pad_base64(b64_content) - - try: - decoded_bytes = base64.urlsafe_b64decode(b64_content) - string = decoded_bytes.decode('utf-8') - - # isdigit on its own would match a lot of other Unicode characters, hence the isascii. - return string.isascii() and string.isdigit() - except (binascii.Error, ValueError): - return False - - @staticmethod - def is_valid_timestamp(b64_content: str) -> bool: - """ - Return True if `b64_content` decodes to a valid timestamp. - - If the timestamp is greater than the Discord epoch, it's probably valid. - See: https://i.imgur.com/7WdehGn.png - """ - b64_content = utils.pad_base64(b64_content) - - try: - decoded_bytes = base64.urlsafe_b64decode(b64_content) - timestamp = int.from_bytes(decoded_bytes, byteorder="big") - except (binascii.Error, ValueError) as e: - log.debug(f"Failed to decode token timestamp '{b64_content}': {e}") - return False - - # Seems like newer tokens don't need the epoch added, but add anyway since an upper bound - # is not checked. - if timestamp + TOKEN_EPOCH >= DISCORD_EPOCH: - return True - else: - log.debug(f"Invalid token timestamp '{b64_content}': smaller than Discord epoch") - return False - - -def setup(bot: Bot) -> None: - """Load the TokenRemover cog.""" - bot.add_cog(TokenRemover(bot)) diff --git a/bot/cogs/filters/webhook_remover.py b/bot/cogs/filters/webhook_remover.py deleted file mode 100644 index 5812da87c..000000000 --- a/bot/cogs/filters/webhook_remover.py +++ /dev/null @@ -1,84 +0,0 @@ -import logging -import re - -from discord import Colour, Message, NotFound -from discord.ext.commands import Cog - -from bot.bot import Bot -from bot.cogs.moderation.modlog import ModLog -from bot.constants import Channels, Colours, Event, Icons - -WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\d+/)\S+/?", re.IGNORECASE) - -ALERT_MESSAGE_TEMPLATE = ( - "{user}, looks like you posted a Discord webhook URL. Therefore, your " - "message has been removed. Your webhook may have been **compromised** so " - "please re-create the webhook **immediately**. If you believe this was " - "mistake, please let us know." -) - -log = logging.getLogger(__name__) - - -class WebhookRemover(Cog): - """Scan messages to detect Discord webhooks links.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @property - def mod_log(self) -> ModLog: - """Get current instance of `ModLog`.""" - return self.bot.get_cog("ModLog") - - async def delete_and_respond(self, msg: Message, redacted_url: str) -> None: - """Delete `msg` and send a warning that it contained the Discord webhook `redacted_url`.""" - # Don't log this, due internal delete, not by user. Will make different entry. - self.mod_log.ignore(Event.message_delete, msg.id) - - try: - await msg.delete() - except NotFound: - log.debug(f"Failed to remove webhook in message {msg.id}: message already deleted.") - return - - await msg.channel.send(ALERT_MESSAGE_TEMPLATE.format(user=msg.author.mention)) - - message = ( - f"{msg.author} (`{msg.author.id}`) posted a Discord webhook URL " - f"to #{msg.channel}. Webhook URL was `{redacted_url}`" - ) - log.debug(message) - - # Send entry to moderation alerts. - await self.mod_log.send_log_message( - icon_url=Icons.token_removed, - colour=Colour(Colours.soft_red), - title="Discord webhook URL removed!", - text=message, - thumbnail=msg.author.avatar_url_as(static_format="png"), - channel_id=Channels.mod_alerts - ) - - self.bot.stats.incr("tokens.removed_webhooks") - - @Cog.listener() - async def on_message(self, msg: Message) -> None: - """Check if a Discord webhook URL is in `message`.""" - # Ignore DMs; can't delete messages in there anyway. - if not msg.guild or msg.author.bot: - return - - matches = WEBHOOK_URL_RE.search(msg.content) - if matches: - await self.delete_and_respond(msg, matches[1] + "xxx") - - @Cog.listener() - async def on_message_edit(self, before: Message, after: Message) -> None: - """Check if a Discord webhook URL is in the edited message `after`.""" - await self.on_message(after) - - -def setup(bot: Bot) -> None: - """Load `WebhookRemover` cog.""" - bot.add_cog(WebhookRemover(bot)) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py deleted file mode 100644 index 57094751e..000000000 --- a/bot/cogs/help_channels.py +++ /dev/null @@ -1,944 +0,0 @@ -import asyncio -import json -import logging -import random -import typing as t -from collections import deque -from datetime import datetime, timedelta, timezone -from pathlib import Path - -import discord -import discord.abc -from discord.ext import commands - -from bot import constants -from bot.bot import Bot -from bot.utils import RedisCache -from bot.utils.checks import with_role_check -from bot.utils.scheduling import Scheduler - -log = logging.getLogger(__name__) - -ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" -MAX_CHANNELS_PER_CATEGORY = 50 -EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, constants.Channels.cooldown) - -HELP_CHANNEL_TOPIC = """ -This is a Python help channel. You can claim your own help channel in the Python Help: Available category. -""" - -AVAILABLE_MSG = f""" -This help channel is now **available**, which means that you can claim it by simply typing your \ -question into it. Once claimed, the channel will move into the **Python Help: Occupied** category, \ -and will be yours until it has been inactive for {constants.HelpChannels.idle_minutes} minutes or \ -is closed manually with `!close`. When that happens, it will be set to **dormant** and moved into \ -the **Help: Dormant** category. - -Try to write the best question you can by providing a detailed description and telling us what \ -you've tried already. For more information on asking a good question, \ -check out our guide on [asking good questions]({ASKING_GUIDE_URL}). -""" - -DORMANT_MSG = f""" -This help channel has been marked as **dormant**, and has been moved into the **Help: Dormant** \ -category at the bottom of the channel list. It is no longer possible to send messages in this \ -channel until it becomes available again. - -If your question wasn't answered yet, you can claim a new help channel from the \ -**Help: Available** category by simply asking your question again. Consider rephrasing the \ -question to maximize your chance of getting a good answer. If you're not sure how, have a look \ -through our guide for [asking a good question]({ASKING_GUIDE_URL}). -""" - -CoroutineFunc = t.Callable[..., t.Coroutine] - - -class HelpChannels(commands.Cog): - """ - Manage the help channel system of the guild. - - The system is based on a 3-category system: - - Available Category - - * Contains channels which are ready to be occupied by someone who needs help - * Will always contain `constants.HelpChannels.max_available` channels; refilled automatically - from the pool of dormant channels - * Prioritise using the channels which have been dormant for the longest amount of time - * If there are no more dormant channels, the bot will automatically create a new one - * If there are no dormant channels to move, helpers will be notified (see `notify()`) - * When a channel becomes available, the dormant embed will be edited to show `AVAILABLE_MSG` - * User can only claim a channel at an interval `constants.HelpChannels.claim_minutes` - * To keep track of cooldowns, user which claimed a channel will have a temporary role - - In Use Category - - * Contains all channels which are occupied by someone needing help - * Channel moves to dormant category after `constants.HelpChannels.idle_minutes` of being idle - * Command can prematurely mark a channel as dormant - * Channel claimant is allowed to use the command - * Allowed roles for the command are configurable with `constants.HelpChannels.cmd_whitelist` - * When a channel becomes dormant, an embed with `DORMANT_MSG` will be sent - - Dormant Category - - * Contains channels which aren't in use - * Channels are used to refill the Available category - - Help channels are named after the chemical elements in `bot/resources/elements.json`. - """ - - # This cache tracks which channels are claimed by which members. - # RedisCache[discord.TextChannel.id, t.Union[discord.User.id, discord.Member.id]] - help_channel_claimants = RedisCache() - - # This cache maps a help channel to whether it has had any - # activity other than the original claimant. True being no other - # activity and False being other activity. - # RedisCache[discord.TextChannel.id, bool] - unanswered = RedisCache() - - # This dictionary maps a help channel to the time it was claimed - # RedisCache[discord.TextChannel.id, UtcPosixTimestamp] - claim_times = RedisCache() - - # This cache maps a help channel to original question message in same channel. - # RedisCache[discord.TextChannel.id, discord.Message.id] - question_messages = RedisCache() - - def __init__(self, bot: Bot): - self.bot = bot - self.scheduler = Scheduler(self.__class__.__name__) - - # Categories - self.available_category: discord.CategoryChannel = None - self.in_use_category: discord.CategoryChannel = None - self.dormant_category: discord.CategoryChannel = None - - # Queues - self.channel_queue: asyncio.Queue[discord.TextChannel] = None - self.name_queue: t.Deque[str] = None - - self.name_positions = self.get_names() - self.last_notification: t.Optional[datetime] = None - - # Asyncio stuff - self.queue_tasks: t.List[asyncio.Task] = [] - self.ready = asyncio.Event() - self.on_message_lock = asyncio.Lock() - self.init_task = self.bot.loop.create_task(self.init_cog()) - - def cog_unload(self) -> None: - """Cancel the init task and scheduled tasks when the cog unloads.""" - log.trace("Cog unload: cancelling the init_cog task") - self.init_task.cancel() - - log.trace("Cog unload: cancelling the channel queue tasks") - for task in self.queue_tasks: - task.cancel() - - self.scheduler.cancel_all() - - def create_channel_queue(self) -> asyncio.Queue: - """ - Return a queue of dormant channels to use for getting the next available channel. - - The channels are added to the queue in a random order. - """ - log.trace("Creating the channel queue.") - - channels = list(self.get_category_channels(self.dormant_category)) - random.shuffle(channels) - - log.trace("Populating the channel queue with channels.") - queue = asyncio.Queue() - for channel in channels: - queue.put_nowait(channel) - - return queue - - async def create_dormant(self) -> t.Optional[discord.TextChannel]: - """ - Create and return a new channel in the Dormant category. - - The new channel will sync its permission overwrites with the category. - - Return None if no more channel names are available. - """ - log.trace("Getting a name for a new dormant channel.") - - try: - name = self.name_queue.popleft() - except IndexError: - log.debug("No more names available for new dormant channels.") - return None - - log.debug(f"Creating a new dormant channel named {name}.") - return await self.dormant_category.create_text_channel(name, topic=HELP_CHANNEL_TOPIC) - - def create_name_queue(self) -> deque: - """Return a queue of element names to use for creating new channels.""" - log.trace("Creating the chemical element name queue.") - - used_names = self.get_used_names() - - log.trace("Determining the available names.") - available_names = (name for name in self.name_positions if name not in used_names) - - log.trace("Populating the name queue with names.") - return deque(available_names) - - async def dormant_check(self, ctx: commands.Context) -> bool: - """Return True if the user is the help channel claimant or passes the role check.""" - if await self.help_channel_claimants.get(ctx.channel.id) == ctx.author.id: - log.trace(f"{ctx.author} is the help channel claimant, passing the check for dormant.") - self.bot.stats.incr("help.dormant_invoke.claimant") - return True - - log.trace(f"{ctx.author} is not the help channel claimant, checking roles.") - role_check = with_role_check(ctx, *constants.HelpChannels.cmd_whitelist) - - if role_check: - self.bot.stats.incr("help.dormant_invoke.staff") - - return role_check - - @commands.command(name="close", aliases=["dormant", "solved"], enabled=False) - async def close_command(self, ctx: commands.Context) -> None: - """ - Make the current in-use help channel dormant. - - Make the channel dormant if the user passes the `dormant_check`, - delete the message that invoked this, - and reset the send permissions cooldown for the user who started the session. - """ - log.trace("close command invoked; checking if the channel is in-use.") - if ctx.channel.category == self.in_use_category: - if await self.dormant_check(ctx): - await self.remove_cooldown_role(ctx.author) - - # Ignore missing task when cooldown has passed but the channel still isn't dormant. - if ctx.author.id in self.scheduler: - self.scheduler.cancel(ctx.author.id) - - await self.move_to_dormant(ctx.channel, "command") - self.scheduler.cancel(ctx.channel.id) - else: - log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") - - async def get_available_candidate(self) -> discord.TextChannel: - """ - Return a dormant channel to turn into an available channel. - - If no channel is available, wait indefinitely until one becomes available. - """ - log.trace("Getting an available channel candidate.") - - try: - channel = self.channel_queue.get_nowait() - except asyncio.QueueEmpty: - log.info("No candidate channels in the queue; creating a new channel.") - channel = await self.create_dormant() - - if not channel: - log.info("Couldn't create a candidate channel; waiting to get one from the queue.") - await self.notify() - channel = await self.wait_for_dormant_channel() - - return channel - - @staticmethod - def get_clean_channel_name(channel: discord.TextChannel) -> str: - """Return a clean channel name without status emojis prefix.""" - prefix = constants.HelpChannels.name_prefix - try: - # Try to remove the status prefix using the index of the channel prefix - name = channel.name[channel.name.index(prefix):] - log.trace(f"The clean name for `{channel}` is `{name}`") - except ValueError: - # If, for some reason, the channel name does not contain "help-" fall back gracefully - log.info(f"Can't get clean name because `{channel}` isn't prefixed by `{prefix}`.") - name = channel.name - - return name - - @staticmethod - def is_excluded_channel(channel: discord.abc.GuildChannel) -> bool: - """Check if a channel should be excluded from the help channel system.""" - return not isinstance(channel, discord.TextChannel) or channel.id in EXCLUDED_CHANNELS - - def get_category_channels(self, category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: - """Yield the text channels of the `category` in an unsorted manner.""" - log.trace(f"Getting text channels in the category '{category}' ({category.id}).") - - # This is faster than using category.channels because the latter sorts them. - for channel in self.bot.get_guild(constants.Guild.id).channels: - if channel.category_id == category.id and not self.is_excluded_channel(channel): - yield channel - - async def get_in_use_time(self, channel_id: int) -> t.Optional[timedelta]: - """Return the duration `channel_id` has been in use. Return None if it's not in use.""" - log.trace(f"Calculating in use time for channel {channel_id}.") - - claimed_timestamp = await self.claim_times.get(channel_id) - if claimed_timestamp: - claimed = datetime.utcfromtimestamp(claimed_timestamp) - return datetime.utcnow() - claimed - - @staticmethod - def get_names() -> t.List[str]: - """ - Return a truncated list of prefixed element names. - - The amount of names is configured with `HelpChannels.max_total_channels`. - The prefix is configured with `HelpChannels.name_prefix`. - """ - count = constants.HelpChannels.max_total_channels - prefix = constants.HelpChannels.name_prefix - - log.trace(f"Getting the first {count} element names from JSON.") - - with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file: - all_names = json.load(elements_file) - - if prefix: - return [prefix + name for name in all_names[:count]] - else: - return all_names[:count] - - def get_used_names(self) -> t.Set[str]: - """Return channel names which are already being used.""" - log.trace("Getting channel names which are already being used.") - - names = set() - for cat in (self.available_category, self.in_use_category, self.dormant_category): - for channel in self.get_category_channels(cat): - names.add(self.get_clean_channel_name(channel)) - - if len(names) > MAX_CHANNELS_PER_CATEGORY: - log.warning( - f"Too many help channels ({len(names)}) already exist! " - f"Discord only supports {MAX_CHANNELS_PER_CATEGORY} in a category." - ) - - log.trace(f"Got {len(names)} used names: {names}") - return names - - @classmethod - async def get_idle_time(cls, channel: discord.TextChannel) -> t.Optional[int]: - """ - Return the time elapsed, in seconds, since the last message sent in the `channel`. - - Return None if the channel has no messages. - """ - log.trace(f"Getting the idle time for #{channel} ({channel.id}).") - - msg = await cls.get_last_message(channel) - if not msg: - log.debug(f"No idle time available; #{channel} ({channel.id}) has no messages.") - return None - - idle_time = (datetime.utcnow() - msg.created_at).seconds - - log.trace(f"#{channel} ({channel.id}) has been idle for {idle_time} seconds.") - return idle_time - - @staticmethod - async def get_last_message(channel: discord.TextChannel) -> t.Optional[discord.Message]: - """Return the last message sent in the channel or None if no messages exist.""" - log.trace(f"Getting the last message in #{channel} ({channel.id}).") - - try: - return await channel.history(limit=1).next() # noqa: B305 - except discord.NoMoreItems: - log.debug(f"No last message available; #{channel} ({channel.id}) has no messages.") - return None - - async def init_available(self) -> None: - """Initialise the Available category with channels.""" - log.trace("Initialising the Available category with channels.") - - channels = list(self.get_category_channels(self.available_category)) - missing = constants.HelpChannels.max_available - len(channels) - - # If we've got less than `max_available` channel available, we should add some. - if missing > 0: - log.trace(f"Moving {missing} missing channels to the Available category.") - for _ in range(missing): - await self.move_to_available() - - # If for some reason we have more than `max_available` channels available, - # we should move the superfluous ones over to dormant. - elif missing < 0: - log.trace(f"Moving {abs(missing)} superfluous available channels over to the Dormant category.") - for channel in channels[:abs(missing)]: - await self.move_to_dormant(channel, "auto") - - async def init_categories(self) -> None: - """Get the help category objects. Remove the cog if retrieval fails.""" - log.trace("Getting the CategoryChannel objects for the help categories.") - - try: - self.available_category = await self.try_get_channel( - constants.Categories.help_available - ) - self.in_use_category = await self.try_get_channel(constants.Categories.help_in_use) - self.dormant_category = await self.try_get_channel(constants.Categories.help_dormant) - except discord.HTTPException: - log.exception("Failed to get a category; cog will be removed") - self.bot.remove_cog(self.qualified_name) - - async def init_cog(self) -> None: - """Initialise the help channel system.""" - log.trace("Waiting for the guild to be available before initialisation.") - await self.bot.wait_until_guild_available() - - log.trace("Initialising the cog.") - await self.init_categories() - await self.check_cooldowns() - - self.channel_queue = self.create_channel_queue() - self.name_queue = self.create_name_queue() - - log.trace("Moving or rescheduling in-use channels.") - for channel in self.get_category_channels(self.in_use_category): - await self.move_idle_channel(channel, has_task=False) - - # Prevent the command from being used until ready. - # The ready event wasn't used because channels could change categories between the time - # the command is invoked and the cog is ready (e.g. if move_idle_channel wasn't called yet). - # This may confuse users. So would potentially long delays for the cog to become ready. - self.close_command.enabled = True - - await self.init_available() - - log.info("Cog is ready!") - self.ready.set() - - self.report_stats() - - def report_stats(self) -> None: - """Report the channel count stats.""" - total_in_use = sum(1 for _ in self.get_category_channels(self.in_use_category)) - total_available = sum(1 for _ in self.get_category_channels(self.available_category)) - total_dormant = sum(1 for _ in self.get_category_channels(self.dormant_category)) - - self.bot.stats.gauge("help.total.in_use", total_in_use) - self.bot.stats.gauge("help.total.available", total_available) - self.bot.stats.gauge("help.total.dormant", total_dormant) - - @staticmethod - def is_claimant(member: discord.Member) -> bool: - """Return True if `member` has the 'Help Cooldown' role.""" - return any(constants.Roles.help_cooldown == role.id for role in member.roles) - - def match_bot_embed(self, message: t.Optional[discord.Message], description: str) -> bool: - """Return `True` if the bot's `message`'s embed description matches `description`.""" - if not message or not message.embeds: - return False - - bot_msg_desc = message.embeds[0].description - if bot_msg_desc is discord.Embed.Empty: - log.trace("Last message was a bot embed but it was empty.") - return False - return message.author == self.bot.user and bot_msg_desc.strip() == description.strip() - - @staticmethod - def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: - """Return True if `channel` is within a category with `category_id`.""" - actual_category = getattr(channel, "category", None) - return actual_category is not None and actual_category.id == category_id - - async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None: - """ - Make the `channel` dormant if idle or schedule the move if still active. - - If `has_task` is True and rescheduling is required, the extant task to make the channel - dormant will first be cancelled. - """ - log.trace(f"Handling in-use channel #{channel} ({channel.id}).") - - if not await self.is_empty(channel): - idle_seconds = constants.HelpChannels.idle_minutes * 60 - else: - idle_seconds = constants.HelpChannels.deleted_idle_minutes * 60 - - time_elapsed = await self.get_idle_time(channel) - - if time_elapsed is None or time_elapsed >= idle_seconds: - log.info( - f"#{channel} ({channel.id}) is idle longer than {idle_seconds} seconds " - f"and will be made dormant." - ) - - await self.move_to_dormant(channel, "auto") - else: - # Cancel the existing task, if any. - if has_task: - self.scheduler.cancel(channel.id) - - delay = idle_seconds - time_elapsed - log.info( - f"#{channel} ({channel.id}) is still active; " - f"scheduling it to be moved after {delay} seconds." - ) - - self.scheduler.schedule_later(delay, channel.id, self.move_idle_channel(channel)) - - async def move_to_bottom_position(self, channel: discord.TextChannel, category_id: int, **options) -> None: - """ - Move the `channel` to the bottom position of `category` and edit channel attributes. - - To ensure "stable sorting", we use the `bulk_channel_update` endpoint and provide the current - positions of the other channels in the category as-is. This should make sure that the channel - really ends up at the bottom of the category. - - If `options` are provided, the channel will be edited after the move is completed. This is the - same order of operations that `discord.TextChannel.edit` uses. For information on available - options, see the documention on `discord.TextChannel.edit`. While possible, position-related - options should be avoided, as it may interfere with the category move we perform. - """ - # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had. - category = await self.try_get_channel(category_id) - - payload = [{"id": c.id, "position": c.position} for c in category.channels] - - # Calculate the bottom position based on the current highest position in the category. If the - # category is currently empty, we simply use the current position of the channel to avoid making - # unnecessary changes to positions in the guild. - bottom_position = payload[-1]["position"] + 1 if payload else channel.position - - payload.append( - { - "id": channel.id, - "position": bottom_position, - "parent_id": category.id, - "lock_permissions": True, - } - ) - - # We use d.py's method to ensure our request is processed by d.py's rate limit manager - await self.bot.http.bulk_channel_update(category.guild.id, payload) - - # Now that the channel is moved, we can edit the other attributes - if options: - await channel.edit(**options) - - async def move_to_available(self) -> None: - """Make a channel available.""" - log.trace("Making a channel available.") - - channel = await self.get_available_candidate() - log.info(f"Making #{channel} ({channel.id}) available.") - - await self.send_available_message(channel) - - log.trace(f"Moving #{channel} ({channel.id}) to the Available category.") - - await self.move_to_bottom_position( - channel=channel, - category_id=constants.Categories.help_available, - ) - - self.report_stats() - - async def move_to_dormant(self, channel: discord.TextChannel, caller: str) -> None: - """ - Make the `channel` dormant. - - A caller argument is provided for metrics. - """ - log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") - - await self.help_channel_claimants.delete(channel.id) - await self.move_to_bottom_position( - channel=channel, - category_id=constants.Categories.help_dormant, - ) - - self.bot.stats.incr(f"help.dormant_calls.{caller}") - - in_use_time = await self.get_in_use_time(channel.id) - if in_use_time: - self.bot.stats.timing("help.in_use_time", in_use_time) - - unanswered = await self.unanswered.get(channel.id) - if unanswered: - self.bot.stats.incr("help.sessions.unanswered") - elif unanswered is not None: - self.bot.stats.incr("help.sessions.answered") - - log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.") - log.trace(f"Sending dormant message for #{channel} ({channel.id}).") - embed = discord.Embed(description=DORMANT_MSG) - await channel.send(embed=embed) - - await self.unpin(channel) - - log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.") - self.channel_queue.put_nowait(channel) - self.report_stats() - - async def move_to_in_use(self, channel: discord.TextChannel) -> None: - """Make a channel in-use and schedule it to be made dormant.""" - log.info(f"Moving #{channel} ({channel.id}) to the In Use category.") - - await self.move_to_bottom_position( - channel=channel, - category_id=constants.Categories.help_in_use, - ) - - timeout = constants.HelpChannels.idle_minutes * 60 - - log.trace(f"Scheduling #{channel} ({channel.id}) to become dormant in {timeout} sec.") - self.scheduler.schedule_later(timeout, channel.id, self.move_idle_channel(channel)) - self.report_stats() - - async def notify(self) -> None: - """ - Send a message notifying about a lack of available help channels. - - Configuration: - - * `HelpChannels.notify` - toggle notifications - * `HelpChannels.notify_channel` - destination channel for notifications - * `HelpChannels.notify_minutes` - minimum interval between notifications - * `HelpChannels.notify_roles` - roles mentioned in notifications - """ - if not constants.HelpChannels.notify: - return - - log.trace("Notifying about lack of channels.") - - if self.last_notification: - elapsed = (datetime.utcnow() - self.last_notification).seconds - minimum_interval = constants.HelpChannels.notify_minutes * 60 - should_send = elapsed >= minimum_interval - else: - should_send = True - - if not should_send: - log.trace("Notification not sent because it's too recent since the previous one.") - return - - try: - log.trace("Sending notification message.") - - channel = self.bot.get_channel(constants.HelpChannels.notify_channel) - mentions = " ".join(f"<@&{role}>" for role in constants.HelpChannels.notify_roles) - allowed_roles = [discord.Object(id_) for id_ in constants.HelpChannels.notify_roles] - - message = await channel.send( - f"{mentions} A new available help channel is needed but there " - f"are no more dormant ones. Consider freeing up some in-use channels manually by " - f"using the `{constants.Bot.prefix}dormant` command within the channels.", - allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) - ) - - self.bot.stats.incr("help.out_of_channel_alerts") - - self.last_notification = message.created_at - except Exception: - # Handle it here cause this feature isn't critical for the functionality of the system. - log.exception("Failed to send notification about lack of dormant channels!") - - async def check_for_answer(self, message: discord.Message) -> None: - """Checks for whether new content in a help channel comes from non-claimants.""" - channel = message.channel - - # Confirm the channel is an in use help channel - if self.is_in_category(channel, constants.Categories.help_in_use): - log.trace(f"Checking if #{channel} ({channel.id}) has been answered.") - - # Check if there is an entry in unanswered - if await self.unanswered.contains(channel.id): - claimant_id = await self.help_channel_claimants.get(channel.id) - if not claimant_id: - # The mapping for this channel doesn't exist, we can't do anything. - return - - # Check the message did not come from the claimant - if claimant_id != message.author.id: - # Mark the channel as answered - await self.unanswered.set(channel.id, False) - - @commands.Cog.listener() - async def on_message(self, message: discord.Message) -> None: - """Move an available channel to the In Use category and replace it with a dormant one.""" - if message.author.bot: - return # Ignore messages sent by bots. - - channel = message.channel - - await self.check_for_answer(message) - - if not self.is_in_category(channel, constants.Categories.help_available) or self.is_excluded_channel(channel): - return # Ignore messages outside the Available category or in excluded channels. - - log.trace("Waiting for the cog to be ready before processing messages.") - await self.ready.wait() - - log.trace("Acquiring lock to prevent a channel from being processed twice...") - async with self.on_message_lock: - log.trace(f"on_message lock acquired for {message.id}.") - - if not self.is_in_category(channel, constants.Categories.help_available): - log.debug( - f"Message {message.id} will not make #{channel} ({channel.id}) in-use " - f"because another message in the channel already triggered that." - ) - return - - log.info(f"Channel #{channel} was claimed by `{message.author.id}`.") - await self.move_to_in_use(channel) - await self.revoke_send_permissions(message.author) - - await self.pin(message) - - # Add user with channel for dormant check. - await self.help_channel_claimants.set(channel.id, message.author.id) - - self.bot.stats.incr("help.claimed") - - # Must use a timezone-aware datetime to ensure a correct POSIX timestamp. - timestamp = datetime.now(timezone.utc).timestamp() - await self.claim_times.set(channel.id, timestamp) - - await self.unanswered.set(channel.id, True) - - log.trace(f"Releasing on_message lock for {message.id}.") - - # Move a dormant channel to the Available category to fill in the gap. - # This is done last and outside the lock because it may wait indefinitely for a channel to - # be put in the queue. - await self.move_to_available() - - @commands.Cog.listener() - async def on_message_delete(self, msg: discord.Message) -> None: - """ - Reschedule an in-use channel to become dormant sooner if the channel is empty. - - The new time for the dormant task is configured with `HelpChannels.deleted_idle_minutes`. - """ - if not self.is_in_category(msg.channel, constants.Categories.help_in_use): - return - - if not await self.is_empty(msg.channel): - return - - log.info(f"Claimant of #{msg.channel} ({msg.author}) deleted message, channel is empty now. Rescheduling task.") - - # Cancel existing dormant task before scheduling new. - self.scheduler.cancel(msg.channel.id) - - delay = constants.HelpChannels.deleted_idle_minutes * 60 - self.scheduler.schedule_later(delay, msg.channel.id, self.move_idle_channel(msg.channel)) - - async def is_empty(self, channel: discord.TextChannel) -> bool: - """Return True if there's an AVAILABLE_MSG and the messages leading up are bot messages.""" - log.trace(f"Checking if #{channel} ({channel.id}) is empty.") - - # A limit of 100 results in a single API call. - # If AVAILABLE_MSG isn't found within 100 messages, then assume the channel is not empty. - # Not gonna do an extensive search for it cause it's too expensive. - async for msg in channel.history(limit=100): - if not msg.author.bot: - log.trace(f"#{channel} ({channel.id}) has a non-bot message.") - return False - - if self.match_bot_embed(msg, AVAILABLE_MSG): - log.trace(f"#{channel} ({channel.id}) has the available message embed.") - return True - - return False - - async def check_cooldowns(self) -> None: - """Remove expired cooldowns and re-schedule active ones.""" - log.trace("Checking all cooldowns to remove or re-schedule them.") - guild = self.bot.get_guild(constants.Guild.id) - cooldown = constants.HelpChannels.claim_minutes * 60 - - for channel_id, member_id in await self.help_channel_claimants.items(): - member = guild.get_member(member_id) - if not member: - continue # Member probably left the guild. - - in_use_time = await self.get_in_use_time(channel_id) - - if not in_use_time or in_use_time.seconds > cooldown: - # Remove the role if no claim time could be retrieved or if the cooldown expired. - # Since the channel is in the claimants cache, it is definitely strange for a time - # to not exist. However, it isn't a reason to keep the user stuck with a cooldown. - await self.remove_cooldown_role(member) - else: - # The member is still on a cooldown; re-schedule it for the remaining time. - delay = cooldown - in_use_time.seconds - self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member)) - - async def add_cooldown_role(self, member: discord.Member) -> None: - """Add the help cooldown role to `member`.""" - log.trace(f"Adding cooldown role for {member} ({member.id}).") - await self._change_cooldown_role(member, member.add_roles) - - async def remove_cooldown_role(self, member: discord.Member) -> None: - """Remove the help cooldown role from `member`.""" - log.trace(f"Removing cooldown role for {member} ({member.id}).") - await self._change_cooldown_role(member, member.remove_roles) - - async def _change_cooldown_role(self, member: discord.Member, coro_func: CoroutineFunc) -> None: - """ - Change `member`'s cooldown role via awaiting `coro_func` and handle errors. - - `coro_func` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`. - """ - guild = self.bot.get_guild(constants.Guild.id) - role = guild.get_role(constants.Roles.help_cooldown) - if role is None: - log.warning(f"Help cooldown role ({constants.Roles.help_cooldown}) could not be found!") - return - - try: - await coro_func(role) - except discord.NotFound: - log.debug(f"Failed to change role for {member} ({member.id}): member not found") - except discord.Forbidden: - log.debug( - f"Forbidden to change role for {member} ({member.id}); " - f"possibly due to role hierarchy" - ) - except discord.HTTPException as e: - log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}") - - async def revoke_send_permissions(self, member: discord.Member) -> None: - """ - Disallow `member` to send messages in the Available category for a certain time. - - The time until permissions are reinstated can be configured with - `HelpChannels.claim_minutes`. - """ - log.trace( - f"Revoking {member}'s ({member.id}) send message permissions in the Available category." - ) - - await self.add_cooldown_role(member) - - # Cancel the existing task, if any. - # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner). - if member.id in self.scheduler: - self.scheduler.cancel(member.id) - - delay = constants.HelpChannels.claim_minutes * 60 - self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member)) - - async def send_available_message(self, channel: discord.TextChannel) -> None: - """Send the available message by editing a dormant message or sending a new message.""" - channel_info = f"#{channel} ({channel.id})" - log.trace(f"Sending available message in {channel_info}.") - - embed = discord.Embed(description=AVAILABLE_MSG) - - msg = await self.get_last_message(channel) - if self.match_bot_embed(msg, DORMANT_MSG): - log.trace(f"Found dormant message {msg.id} in {channel_info}; editing it.") - await msg.edit(embed=embed) - else: - log.trace(f"Dormant message not found in {channel_info}; sending a new message.") - await channel.send(embed=embed) - - async def try_get_channel(self, channel_id: int) -> discord.abc.GuildChannel: - """Attempt to get or fetch a channel and return it.""" - log.trace(f"Getting the channel {channel_id}.") - - channel = self.bot.get_channel(channel_id) - if not channel: - log.debug(f"Channel {channel_id} is not in cache; fetching from API.") - channel = await self.bot.fetch_channel(channel_id) - - log.trace(f"Channel #{channel} ({channel_id}) retrieved.") - return channel - - async def pin_wrapper(self, msg_id: int, channel: discord.TextChannel, *, pin: bool) -> bool: - """ - Pin message `msg_id` in `channel` if `pin` is True or unpin if it's False. - - Return True if successful and False otherwise. - """ - channel_str = f"#{channel} ({channel.id})" - if pin: - func = self.bot.http.pin_message - verb = "pin" - else: - func = self.bot.http.unpin_message - verb = "unpin" - - try: - await func(channel.id, msg_id) - except discord.HTTPException as e: - if e.code == 10008: - log.debug(f"Message {msg_id} in {channel_str} doesn't exist; can't {verb}.") - else: - log.exception( - f"Error {verb}ning message {msg_id} in {channel_str}: {e.status} ({e.code})" - ) - return False - else: - log.trace(f"{verb.capitalize()}ned message {msg_id} in {channel_str}.") - return True - - async def pin(self, message: discord.Message) -> None: - """Pin an initial question `message` and store it in a cache.""" - if await self.pin_wrapper(message.id, message.channel, pin=True): - await self.question_messages.set(message.channel.id, message.id) - - async def unpin(self, channel: discord.TextChannel) -> None: - """Unpin the initial question message sent in `channel`.""" - msg_id = await self.question_messages.pop(channel.id) - if msg_id is None: - log.debug(f"#{channel} ({channel.id}) doesn't have a message pinned.") - else: - await self.pin_wrapper(msg_id, channel, pin=False) - - async def wait_for_dormant_channel(self) -> discord.TextChannel: - """Wait for a dormant channel to become available in the queue and return it.""" - log.trace("Waiting for a dormant channel.") - - task = asyncio.create_task(self.channel_queue.get()) - self.queue_tasks.append(task) - channel = await task - - log.trace(f"Channel #{channel} ({channel.id}) finally retrieved from the queue.") - self.queue_tasks.remove(task) - - return channel - - -def validate_config() -> None: - """Raise a ValueError if the cog's config is invalid.""" - log.trace("Validating config.") - total = constants.HelpChannels.max_total_channels - available = constants.HelpChannels.max_available - - if total == 0 or available == 0: - raise ValueError("max_total_channels and max_available and must be greater than 0.") - - if total < available: - raise ValueError( - f"max_total_channels ({total}) must be greater than or equal to max_available " - f"({available})." - ) - - if total > MAX_CHANNELS_PER_CATEGORY: - raise ValueError( - f"max_total_channels ({total}) must be less than or equal to " - f"{MAX_CHANNELS_PER_CATEGORY} due to Discord's limit on channels per category." - ) - - -def setup(bot: Bot) -> None: - """Load the HelpChannels cog.""" - try: - validate_config() - except ValueError as e: - log.error(f"HelpChannels cog will not be loaded due to misconfiguration: {e}") - else: - bot.add_cog(HelpChannels(bot)) diff --git a/bot/cogs/info/__init__.py b/bot/cogs/info/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/bot/cogs/info/doc.py b/bot/cogs/info/doc.py deleted file mode 100644 index 204cffb37..000000000 --- a/bot/cogs/info/doc.py +++ /dev/null @@ -1,511 +0,0 @@ -import asyncio -import functools -import logging -import re -import textwrap -from collections import OrderedDict -from contextlib import suppress -from types import SimpleNamespace -from typing import Any, Callable, Optional, Tuple - -import discord -from bs4 import BeautifulSoup -from bs4.element import PageElement, Tag -from discord.errors import NotFound -from discord.ext import commands -from markdownify import MarkdownConverter -from requests import ConnectTimeout, ConnectionError, HTTPError -from sphinx.ext import intersphinx -from urllib3.exceptions import ProtocolError - -from bot.bot import Bot -from bot.constants import MODERATION_ROLES, RedirectOutput -from bot.converters import ValidPythonIdentifier, ValidURL -from bot.decorators import with_role -from bot.pagination import LinePaginator - - -log = logging.getLogger(__name__) -logging.getLogger('urllib3').setLevel(logging.WARNING) - -# Since Intersphinx is intended to be used with Sphinx, -# we need to mock its configuration. -SPHINX_MOCK_APP = SimpleNamespace( - config=SimpleNamespace( - intersphinx_timeout=3, - tls_verify=True, - user_agent="python3:python-discord/bot:1.0.0" - ) -) - -NO_OVERRIDE_GROUPS = ( - "2to3fixer", - "token", - "label", - "pdbcommand", - "term", -) -NO_OVERRIDE_PACKAGES = ( - "python", -) - -SEARCH_END_TAG_ATTRS = ( - "data", - "function", - "class", - "exception", - "seealso", - "section", - "rubric", - "sphinxsidebar", -) -UNWANTED_SIGNATURE_SYMBOLS_RE = re.compile(r"\[source]|\\\\|¶") -WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)") - -FAILED_REQUEST_RETRY_AMOUNT = 3 -NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay - - -def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable: - """ - LRU cache implementation for coroutines. - - Once the cache exceeds the maximum size, keys are deleted in FIFO order. - - An offset may be optionally provided to be applied to the coroutine's arguments when creating the cache key. - """ - # Assign the cache to the function itself so we can clear it from outside. - async_cache.cache = OrderedDict() - - def decorator(function: Callable) -> Callable: - """Define the async_cache decorator.""" - @functools.wraps(function) - async def wrapper(*args) -> Any: - """Decorator wrapper for the caching logic.""" - key = ':'.join(args[arg_offset:]) - - value = async_cache.cache.get(key) - if value is None: - if len(async_cache.cache) > max_size: - async_cache.cache.popitem(last=False) - - async_cache.cache[key] = await function(*args) - return async_cache.cache[key] - return wrapper - return decorator - - -class DocMarkdownConverter(MarkdownConverter): - """Subclass markdownify's MarkdownCoverter to provide custom conversion methods.""" - - def convert_code(self, el: PageElement, text: str) -> str: - """Undo `markdownify`s underscore escaping.""" - return f"`{text}`".replace('\\', '') - - def convert_pre(self, el: PageElement, text: str) -> str: - """Wrap any codeblocks in `py` for syntax highlighting.""" - code = ''.join(el.strings) - return f"```py\n{code}```" - - -def markdownify(html: str) -> DocMarkdownConverter: - """Create a DocMarkdownConverter object from the input html.""" - return DocMarkdownConverter(bullets='•').convert(html) - - -class InventoryURL(commands.Converter): - """ - Represents an Intersphinx inventory URL. - - This converter checks whether intersphinx accepts the given inventory URL, and raises - `BadArgument` if that is not the case. - - Otherwise, it simply passes through the given URL. - """ - - @staticmethod - async def convert(ctx: commands.Context, url: str) -> str: - """Convert url to Intersphinx inventory URL.""" - try: - intersphinx.fetch_inventory(SPHINX_MOCK_APP, '', url) - except AttributeError: - raise commands.BadArgument(f"Failed to fetch Intersphinx inventory from URL `{url}`.") - except ConnectionError: - if url.startswith('https'): - raise commands.BadArgument( - f"Cannot establish a connection to `{url}`. Does it support HTTPS?" - ) - raise commands.BadArgument(f"Cannot connect to host with URL `{url}`.") - except ValueError: - raise commands.BadArgument( - f"Failed to read Intersphinx inventory from URL `{url}`. " - "Are you sure that it's a valid inventory file?" - ) - return url - - -class Doc(commands.Cog): - """A set of commands for querying & displaying documentation.""" - - def __init__(self, bot: Bot): - self.base_urls = {} - self.bot = bot - self.inventories = {} - self.renamed_symbols = set() - - self.bot.loop.create_task(self.init_refresh_inventory()) - - async def init_refresh_inventory(self) -> None: - """Refresh documentation inventory on cog initialization.""" - await self.bot.wait_until_guild_available() - await self.refresh_inventory() - - async def update_single( - self, package_name: str, base_url: str, inventory_url: str - ) -> None: - """ - Rebuild the inventory for a single package. - - Where: - * `package_name` is the package name to use, appears in the log - * `base_url` is the root documentation URL for the specified package, used to build - absolute paths that link to specific symbols - * `inventory_url` is the absolute URL to the intersphinx inventory, fetched by running - `intersphinx.fetch_inventory` in an executor on the bot's event loop - """ - self.base_urls[package_name] = base_url - - package = await self._fetch_inventory(inventory_url) - if not package: - return None - - for group, value in package.items(): - for symbol, (package_name, _version, relative_doc_url, _) in value.items(): - absolute_doc_url = base_url + relative_doc_url - - if symbol in self.inventories: - group_name = group.split(":")[1] - symbol_base_url = self.inventories[symbol].split("/", 3)[2] - if ( - group_name in NO_OVERRIDE_GROUPS - or any(package in symbol_base_url for package in NO_OVERRIDE_PACKAGES) - ): - - symbol = f"{group_name}.{symbol}" - # If renamed `symbol` already exists, add library name in front to differentiate between them. - if symbol in self.renamed_symbols: - # Split `package_name` because of packages like Pillow that have spaces in them. - symbol = f"{package_name.split()[0]}.{symbol}" - - self.inventories[symbol] = absolute_doc_url - self.renamed_symbols.add(symbol) - continue - - self.inventories[symbol] = absolute_doc_url - - log.trace(f"Fetched inventory for {package_name}.") - - async def refresh_inventory(self) -> None: - """Refresh internal documentation inventory.""" - log.debug("Refreshing documentation inventory...") - - # Clear the old base URLS and inventories to ensure - # that we start from a fresh local dataset. - # Also, reset the cache used for fetching documentation. - self.base_urls.clear() - self.inventories.clear() - self.renamed_symbols.clear() - async_cache.cache = OrderedDict() - - # Run all coroutines concurrently - since each of them performs a HTTP - # request, this speeds up fetching the inventory data heavily. - coros = [ - self.update_single( - package["package"], package["base_url"], package["inventory_url"] - ) for package in await self.bot.api_client.get('bot/documentation-links') - ] - await asyncio.gather(*coros) - - async def get_symbol_html(self, symbol: str) -> Optional[Tuple[list, str]]: - """ - Given a Python symbol, return its signature and description. - - The first tuple element is the signature of the given symbol as a markup-free string, and - the second tuple element is the description of the given symbol with HTML markup included. - - If the given symbol is a module, returns a tuple `(None, str)` - else if the symbol could not be found, returns `None`. - """ - url = self.inventories.get(symbol) - if url is None: - return None - - async with self.bot.http_session.get(url) as response: - html = await response.text(encoding='utf-8') - - # Find the signature header and parse the relevant parts. - symbol_id = url.split('#')[-1] - soup = BeautifulSoup(html, 'lxml') - symbol_heading = soup.find(id=symbol_id) - search_html = str(soup) - - if symbol_heading is None: - return None - - if symbol_id == f"module-{symbol}": - # Get page content from the module headerlink to the - # first tag that has its class in `SEARCH_END_TAG_ATTRS` - start_tag = symbol_heading.find("a", attrs={"class": "headerlink"}) - if start_tag is None: - return [], "" - - end_tag = start_tag.find_next(self._match_end_tag) - if end_tag is None: - return [], "" - - description_start_index = search_html.find(str(start_tag.parent)) + len(str(start_tag.parent)) - description_end_index = search_html.find(str(end_tag)) - description = search_html[description_start_index:description_end_index] - signatures = None - - else: - signatures = [] - description = str(symbol_heading.find_next_sibling("dd")) - description_pos = search_html.find(description) - # Get text of up to 3 signatures, remove unwanted symbols - for element in [symbol_heading] + symbol_heading.find_next_siblings("dt", limit=2): - signature = UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element.text) - if signature and search_html.find(str(element)) < description_pos: - signatures.append(signature) - - return signatures, description.replace('¶', '') - - @async_cache(arg_offset=1) - async def get_symbol_embed(self, symbol: str) -> Optional[discord.Embed]: - """ - Attempt to scrape and fetch the data for the given `symbol`, and build an embed from its contents. - - If the symbol is known, an Embed with documentation about it is returned. - """ - scraped_html = await self.get_symbol_html(symbol) - if scraped_html is None: - return None - - signatures = scraped_html[0] - permalink = self.inventories[symbol] - description = markdownify(scraped_html[1]) - - # Truncate the description of the embed to the last occurrence - # of a double newline (interpreted as a paragraph) before index 1000. - if len(description) > 1000: - shortened = description[:1000] - description_cutoff = shortened.rfind('\n\n', 100) - if description_cutoff == -1: - # Search the shortened version for cutoff points in decreasing desirability, - # cutoff at 1000 if none are found. - for string in (". ", ", ", ",", " "): - description_cutoff = shortened.rfind(string) - if description_cutoff != -1: - break - else: - description_cutoff = 1000 - description = description[:description_cutoff] - - # If there is an incomplete code block, cut it out - if description.count("```") % 2: - codeblock_start = description.rfind('```py') - description = description[:codeblock_start].rstrip() - description += f"... [read more]({permalink})" - - description = WHITESPACE_AFTER_NEWLINES_RE.sub('', description) - if signatures is None: - # If symbol is a module, don't show signature. - embed_description = description - - elif not signatures: - # It's some "meta-page", for example: - # https://docs.djangoproject.com/en/dev/ref/views/#module-django.views - embed_description = "This appears to be a generic page not tied to a specific symbol." - - else: - embed_description = "".join(f"```py\n{textwrap.shorten(signature, 500)}```" for signature in signatures) - embed_description += f"\n{description}" - - embed = discord.Embed( - title=f'`{symbol}`', - url=permalink, - description=embed_description - ) - # Show all symbols with the same name that were renamed in the footer. - embed.set_footer( - text=", ".join(renamed for renamed in self.renamed_symbols - {symbol} if renamed.endswith(f".{symbol}")) - ) - return embed - - @commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True) - async def docs_group(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None: - """Lookup documentation for Python symbols.""" - await ctx.invoke(self.get_command, symbol) - - @docs_group.command(name='get', aliases=('g',)) - async def get_command(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None: - """ - Return a documentation embed for a given symbol. - - If no symbol is given, return a list of all available inventories. - - Examples: - !docs - !docs aiohttp - !docs aiohttp.ClientSession - !docs get aiohttp.ClientSession - """ - if symbol is None: - inventory_embed = discord.Embed( - title=f"All inventories (`{len(self.base_urls)}` total)", - colour=discord.Colour.blue() - ) - - lines = sorted(f"• [`{name}`]({url})" for name, url in self.base_urls.items()) - if self.base_urls: - await LinePaginator.paginate(lines, ctx, inventory_embed, max_size=400, empty=False) - - else: - inventory_embed.description = "Hmmm, seems like there's nothing here yet." - await ctx.send(embed=inventory_embed) - - else: - # Fetching documentation for a symbol (at least for the first time, since - # caching is used) takes quite some time, so let's send typing to indicate - # that we got the command, but are still working on it. - async with ctx.typing(): - doc_embed = await self.get_symbol_embed(symbol) - - if doc_embed is None: - error_embed = discord.Embed( - description=f"Sorry, I could not find any documentation for `{symbol}`.", - colour=discord.Colour.red() - ) - error_message = await ctx.send(embed=error_embed) - with suppress(NotFound): - await error_message.delete(delay=NOT_FOUND_DELETE_DELAY) - await ctx.message.delete(delay=NOT_FOUND_DELETE_DELAY) - else: - await ctx.send(embed=doc_embed) - - @docs_group.command(name='set', aliases=('s',)) - @with_role(*MODERATION_ROLES) - async def set_command( - self, ctx: commands.Context, package_name: ValidPythonIdentifier, - base_url: ValidURL, inventory_url: InventoryURL - ) -> None: - """ - Adds a new documentation metadata object to the site's database. - - The database will update the object, should an existing item with the specified `package_name` already exist. - - Example: - !docs set \ - python \ - https://docs.python.org/3/ \ - https://docs.python.org/3/objects.inv - """ - body = { - 'package': package_name, - 'base_url': base_url, - 'inventory_url': inventory_url - } - await self.bot.api_client.post('bot/documentation-links', json=body) - - log.info( - f"User @{ctx.author} ({ctx.author.id}) added a new documentation package:\n" - f"Package name: {package_name}\n" - f"Base url: {base_url}\n" - f"Inventory URL: {inventory_url}" - ) - - # Rebuilding the inventory can take some time, so lets send out a - # typing event to show that the Bot is still working. - async with ctx.typing(): - await self.refresh_inventory() - await ctx.send(f"Added package `{package_name}` to database and refreshed inventory.") - - @docs_group.command(name='delete', aliases=('remove', 'rm', 'd')) - @with_role(*MODERATION_ROLES) - async def delete_command(self, ctx: commands.Context, package_name: ValidPythonIdentifier) -> None: - """ - Removes the specified package from the database. - - Examples: - !docs delete aiohttp - """ - await self.bot.api_client.delete(f'bot/documentation-links/{package_name}') - - async with ctx.typing(): - # Rebuild the inventory to ensure that everything - # that was from this package is properly deleted. - await self.refresh_inventory() - await ctx.send(f"Successfully deleted `{package_name}` and refreshed inventory.") - - @docs_group.command(name="refresh", aliases=("rfsh", "r")) - @with_role(*MODERATION_ROLES) - async def refresh_command(self, ctx: commands.Context) -> None: - """Refresh inventories and send differences to channel.""" - old_inventories = set(self.base_urls) - with ctx.typing(): - await self.refresh_inventory() - # Get differences of added and removed inventories - added = ', '.join(inv for inv in self.base_urls if inv not in old_inventories) - if added: - added = f"+ {added}" - - removed = ', '.join(inv for inv in old_inventories if inv not in self.base_urls) - if removed: - removed = f"- {removed}" - - embed = discord.Embed( - title="Inventories refreshed", - description=f"```diff\n{added}\n{removed}```" if added or removed else "" - ) - await ctx.send(embed=embed) - - async def _fetch_inventory(self, inventory_url: str) -> Optional[dict]: - """Get and return inventory from `inventory_url`. If fetching fails, return None.""" - fetch_func = functools.partial(intersphinx.fetch_inventory, SPHINX_MOCK_APP, '', inventory_url) - for retry in range(1, FAILED_REQUEST_RETRY_AMOUNT+1): - try: - package = await self.bot.loop.run_in_executor(None, fetch_func) - except ConnectTimeout: - log.error( - f"Fetching of inventory {inventory_url} timed out," - f" trying again. ({retry}/{FAILED_REQUEST_RETRY_AMOUNT})" - ) - except ProtocolError: - log.error( - f"Connection lost while fetching inventory {inventory_url}," - f" trying again. ({retry}/{FAILED_REQUEST_RETRY_AMOUNT})" - ) - except HTTPError as e: - log.error(f"Fetching of inventory {inventory_url} failed with status code {e.response.status_code}.") - return None - except ConnectionError: - log.error(f"Couldn't establish connection to inventory {inventory_url}.") - return None - else: - return package - log.error(f"Fetching of inventory {inventory_url} failed.") - return None - - @staticmethod - def _match_end_tag(tag: Tag) -> bool: - """Matches `tag` if its class value is in `SEARCH_END_TAG_ATTRS` or the tag is table.""" - for attr in SEARCH_END_TAG_ATTRS: - if attr in tag.get("class", ()): - return True - - return tag.name == "table" - - -def setup(bot: Bot) -> None: - """Load the Doc cog.""" - bot.add_cog(Doc(bot)) diff --git a/bot/cogs/info/help.py b/bot/cogs/info/help.py deleted file mode 100644 index 3d1d6fd10..000000000 --- a/bot/cogs/info/help.py +++ /dev/null @@ -1,375 +0,0 @@ -import itertools -import logging -from asyncio import TimeoutError -from collections import namedtuple -from contextlib import suppress -from typing import List, Union - -from discord import Colour, Embed, Member, Message, NotFound, Reaction, User -from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand -from fuzzywuzzy import fuzz, process -from fuzzywuzzy.utils import full_process - -from bot import constants -from bot.constants import Channels, Emojis, STAFF_ROLES -from bot.decorators import redirect_output -from bot.pagination import LinePaginator - -log = logging.getLogger(__name__) - -COMMANDS_PER_PAGE = 8 -DELETE_EMOJI = Emojis.trashcan -PREFIX = constants.Bot.prefix - -Category = namedtuple("Category", ["name", "description", "cogs"]) - - -async def help_cleanup(bot: Bot, author: Member, message: Message) -> None: - """ - Runs the cleanup for the help command. - - Adds the :trashcan: reaction that, when clicked, will delete the help message. - After a 300 second timeout, the reaction will be removed. - """ - def check(reaction: Reaction, user: User) -> bool: - """Checks the reaction is :trashcan:, the author is original author and messages are the same.""" - return str(reaction) == DELETE_EMOJI and user.id == author.id and reaction.message.id == message.id - - await message.add_reaction(DELETE_EMOJI) - - with suppress(NotFound): - try: - await bot.wait_for("reaction_add", check=check, timeout=300) - await message.delete() - except TimeoutError: - await message.remove_reaction(DELETE_EMOJI, bot.user) - - -class HelpQueryNotFound(ValueError): - """ - Raised when a HelpSession Query doesn't match a command or cog. - - Contains the custom attribute of ``possible_matches``. - - Instances of this object contain a dictionary of any command(s) that were close to matching the - query, where keys are the possible matched command names and values are the likeness match scores. - """ - - def __init__(self, arg: str, possible_matches: dict = None): - super().__init__(arg) - self.possible_matches = possible_matches - - -class CustomHelpCommand(HelpCommand): - """ - An interactive instance for the bot help command. - - Cogs can be grouped into custom categories. All cogs with the same category will be displayed - under a single category name in the help output. Custom categories are defined inside the cogs - as a class attribute named `category`. A description can also be specified with the attribute - `category_description`. If a description is not found in at least one cog, the default will be - the regular description (class docstring) of the first cog found in the category. - """ - - def __init__(self): - super().__init__(command_attrs={"help": "Shows help for bot commands"}) - - @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES) - async def command_callback(self, ctx: Context, *, command: str = None) -> None: - """Attempts to match the provided query with a valid command or cog.""" - # the only reason we need to tamper with this is because d.py does not support "categories", - # so we need to deal with them ourselves. - - bot = ctx.bot - - if command is None: - # quick and easy, send bot help if command is none - mapping = self.get_bot_mapping() - await self.send_bot_help(mapping) - return - - cog_matches = [] - description = None - for cog in bot.cogs.values(): - if hasattr(cog, "category") and cog.category == command: - cog_matches.append(cog) - if hasattr(cog, "category_description"): - description = cog.category_description - - if cog_matches: - category = Category(name=command, description=description, cogs=cog_matches) - await self.send_category_help(category) - return - - # it's either a cog, group, command or subcommand; let the parent class deal with it - await super().command_callback(ctx, command=command) - - async def get_all_help_choices(self) -> set: - """ - Get all the possible options for getting help in the bot. - - This will only display commands the author has permission to run. - - These include: - - Category names - - Cog names - - Group command names (and aliases) - - Command names (and aliases) - - Subcommand names (with parent group and aliases for subcommand, but not including aliases for group) - - Options and choices are case sensitive. - """ - # first get all commands including subcommands and full command name aliases - choices = set() - for command in await self.filter_commands(self.context.bot.walk_commands()): - # the the command or group name - choices.add(str(command)) - - if isinstance(command, Command): - # all aliases if it's just a command - choices.update(command.aliases) - else: - # otherwise we need to add the parent name in - choices.update(f"{command.full_parent_name} {alias}" for alias in command.aliases) - - # all cog names - choices.update(self.context.bot.cogs) - - # all category names - choices.update(cog.category for cog in self.context.bot.cogs.values() if hasattr(cog, "category")) - return choices - - async def command_not_found(self, string: str) -> "HelpQueryNotFound": - """ - Handles when a query does not match a valid command, group, cog or category. - - Will return an instance of the `HelpQueryNotFound` exception with the error message and possible matches. - """ - choices = await self.get_all_help_choices() - - # Run fuzzywuzzy's processor beforehand, and avoid matching if processed string is empty - # This avoids fuzzywuzzy from raising a warning on inputs with only non-alphanumeric characters - if (processed := full_process(string)): - result = process.extractBests(processed, choices, scorer=fuzz.ratio, score_cutoff=60, processor=None) - else: - result = [] - - return HelpQueryNotFound(f'Query "{string}" not found.', dict(result)) - - async def subcommand_not_found(self, command: Command, string: str) -> "HelpQueryNotFound": - """ - Redirects the error to `command_not_found`. - - `command_not_found` deals with searching and getting best choices for both commands and subcommands. - """ - return await self.command_not_found(f"{command.qualified_name} {string}") - - async def send_error_message(self, error: HelpQueryNotFound) -> None: - """Send the error message to the channel.""" - embed = Embed(colour=Colour.red(), title=str(error)) - - if getattr(error, "possible_matches", None): - matches = "\n".join(f"`{match}`" for match in error.possible_matches) - embed.description = f"**Did you mean:**\n{matches}" - - await self.context.send(embed=embed) - - async def command_formatting(self, command: Command) -> Embed: - """ - Takes a command and turns it into an embed. - - It will add an author, command signature + help, aliases and a note if the user can't run the command. - """ - embed = Embed() - embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) - - parent = command.full_parent_name - - name = str(command) if not parent else f"{parent} {command.name}" - command_details = f"**```{PREFIX}{name} {command.signature}```**\n" - - # show command aliases - aliases = ", ".join(f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in command.aliases) - if aliases: - command_details += f"**Can also use:** {aliases}\n\n" - - # check if the user is allowed to run this command - if not await command.can_run(self.context): - command_details += "***You cannot run this command.***\n\n" - - command_details += f"*{command.help or 'No details provided.'}*\n" - embed.description = command_details - - return embed - - async def send_command_help(self, command: Command) -> None: - """Send help for a single command.""" - embed = await self.command_formatting(command) - message = await self.context.send(embed=embed) - await help_cleanup(self.context.bot, self.context.author, message) - - @staticmethod - def get_commands_brief_details(commands_: List[Command], return_as_list: bool = False) -> Union[List[str], str]: - """ - Formats the prefix, command name and signature, and short doc for an iterable of commands. - - return_as_list is helpful for passing these command details into the paginator as a list of command details. - """ - details = [] - for command in commands_: - signature = f" {command.signature}" if command.signature else "" - details.append( - f"\n**`{PREFIX}{command.qualified_name}{signature}`**\n*{command.short_doc or 'No details provided'}*" - ) - if return_as_list: - return details - else: - return "".join(details) - - async def send_group_help(self, group: Group) -> None: - """Sends help for a group command.""" - subcommands = group.commands - - if len(subcommands) == 0: - # no subcommands, just treat it like a regular command - await self.send_command_help(group) - return - - # remove commands that the user can't run and are hidden, and sort by name - commands_ = await self.filter_commands(subcommands, sort=True) - - embed = await self.command_formatting(group) - - command_details = self.get_commands_brief_details(commands_) - if command_details: - embed.description += f"\n**Subcommands:**\n{command_details}" - - message = await self.context.send(embed=embed) - await help_cleanup(self.context.bot, self.context.author, message) - - async def send_cog_help(self, cog: Cog) -> None: - """Send help for a cog.""" - # sort commands by name, and remove any the user cant run or are hidden. - commands_ = await self.filter_commands(cog.get_commands(), sort=True) - - embed = Embed() - embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) - embed.description = f"**{cog.qualified_name}**\n*{cog.description}*" - - command_details = self.get_commands_brief_details(commands_) - if command_details: - embed.description += f"\n\n**Commands:**\n{command_details}" - - message = await self.context.send(embed=embed) - await help_cleanup(self.context.bot, self.context.author, message) - - @staticmethod - def _category_key(command: Command) -> str: - """ - Returns a cog name of a given command for use as a key for `sorted` and `groupby`. - - A zero width space is used as a prefix for results with no cogs to force them last in ordering. - """ - if command.cog: - with suppress(AttributeError): - if command.cog.category: - return f"**{command.cog.category}**" - return f"**{command.cog_name}**" - else: - return "**\u200bNo Category:**" - - async def send_category_help(self, category: Category) -> None: - """ - Sends help for a bot category. - - This sends a brief help for all commands in all cogs registered to the category. - """ - embed = Embed() - embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) - - all_commands = [] - for cog in category.cogs: - all_commands.extend(cog.get_commands()) - - filtered_commands = await self.filter_commands(all_commands, sort=True) - - command_detail_lines = self.get_commands_brief_details(filtered_commands, return_as_list=True) - description = f"**{category.name}**\n*{category.description}*" - - if command_detail_lines: - description += "\n\n**Commands:**" - - await LinePaginator.paginate( - command_detail_lines, - self.context, - embed, - prefix=description, - max_lines=COMMANDS_PER_PAGE, - max_size=2000, - ) - - async def send_bot_help(self, mapping: dict) -> None: - """Sends help for all bot commands and cogs.""" - bot = self.context.bot - - embed = Embed() - embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) - - filter_commands = await self.filter_commands(bot.commands, sort=True, key=self._category_key) - - cog_or_category_pages = [] - - for cog_or_category, _commands in itertools.groupby(filter_commands, key=self._category_key): - sorted_commands = sorted(_commands, key=lambda c: c.name) - - if len(sorted_commands) == 0: - continue - - command_detail_lines = self.get_commands_brief_details(sorted_commands, return_as_list=True) - - # Split cogs or categories which have too many commands to fit in one page. - # The length of commands is included for later use when aggregating into pages for the paginator. - for index in range(0, len(sorted_commands), COMMANDS_PER_PAGE): - truncated_lines = command_detail_lines[index:index + COMMANDS_PER_PAGE] - joined_lines = "".join(truncated_lines) - cog_or_category_pages.append((f"**{cog_or_category}**{joined_lines}", len(truncated_lines))) - - pages = [] - counter = 0 - page = "" - for page_details, length in cog_or_category_pages: - counter += length - if counter > COMMANDS_PER_PAGE: - # force a new page on paginator even if it falls short of the max pages - # since we still want to group categories/cogs. - counter = length - pages.append(page) - page = f"{page_details}\n\n" - else: - page += f"{page_details}\n\n" - - if page: - # add any remaining command help that didn't get added in the last iteration above. - pages.append(page) - - await LinePaginator.paginate(pages, self.context, embed=embed, max_lines=1, max_size=2000) - - -class Help(Cog): - """Custom Embed Pagination Help feature.""" - - def __init__(self, bot: Bot) -> None: - self.bot = bot - self.old_help_command = bot.help_command - bot.help_command = CustomHelpCommand() - bot.help_command.cog = self - - def cog_unload(self) -> None: - """Reset the help command when the cog is unloaded.""" - self.bot.help_command = self.old_help_command - - -def setup(bot: Bot) -> None: - """Load the Help cog.""" - bot.add_cog(Help(bot)) - log.info("Cog loaded: Help") diff --git a/bot/cogs/info/information.py b/bot/cogs/info/information.py deleted file mode 100644 index 8982196d1..000000000 --- a/bot/cogs/info/information.py +++ /dev/null @@ -1,422 +0,0 @@ -import colorsys -import logging -import pprint -import textwrap -from collections import Counter, defaultdict -from string import Template -from typing import Any, Mapping, Optional, Union - -from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, utils -from discord.abc import GuildChannel -from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group -from discord.utils import escape_markdown - -from bot import constants -from bot.bot import Bot -from bot.decorators import in_whitelist, with_role -from bot.pagination import LinePaginator -from bot.utils.checks import InWhitelistCheckFailure, cooldown_with_role_bypass, with_role_check -from bot.utils.time import time_since - -log = logging.getLogger(__name__) - - -class Information(Cog): - """A cog with commands for generating embeds with server info, such as server stats and user info.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @staticmethod - def role_can_read(channel: GuildChannel, role: Role) -> bool: - """Return True if `role` can read messages in `channel`.""" - overwrites = channel.overwrites_for(role) - return overwrites.read_messages is True - - def get_staff_channel_count(self, guild: Guild) -> int: - """ - Get the number of channels that are staff-only. - - We need to know two things about a channel: - - Does the @everyone role have explicit read deny permissions? - - Do staff roles have explicit read allow permissions? - - If the answer to both of these questions is yes, it's a staff channel. - """ - channel_ids = set() - for channel in guild.channels: - if channel.type is ChannelType.category: - continue - - everyone_can_read = self.role_can_read(channel, guild.default_role) - - for role in constants.STAFF_ROLES: - role_can_read = self.role_can_read(channel, guild.get_role(role)) - if role_can_read and not everyone_can_read: - channel_ids.add(channel.id) - break - - return len(channel_ids) - - @staticmethod - def get_channel_type_counts(guild: Guild) -> str: - """Return the total amounts of the various types of channels in `guild`.""" - channel_counter = Counter(c.type for c in guild.channels) - channel_type_list = [] - for channel, count in channel_counter.items(): - channel_type = str(channel).title() - channel_type_list.append(f"{channel_type} channels: {count}") - - channel_type_list = sorted(channel_type_list) - return "\n".join(channel_type_list) - - @with_role(*constants.MODERATION_ROLES) - @command(name="roles") - async def roles_info(self, ctx: Context) -> None: - """Returns a list of all roles and their corresponding IDs.""" - # Sort the roles alphabetically and remove the @everyone role - roles = sorted(ctx.guild.roles[1:], key=lambda role: role.name) - - # Build a list - role_list = [] - for role in roles: - role_list.append(f"`{role.id}` - {role.mention}") - - # Build an embed - embed = Embed( - title=f"Role information (Total {len(roles)} role{'s' * (len(role_list) > 1)})", - colour=Colour.blurple() - ) - - await LinePaginator.paginate(role_list, ctx, embed, empty=False) - - @with_role(*constants.MODERATION_ROLES) - @command(name="role") - async def role_info(self, ctx: Context, *roles: Union[Role, str]) -> None: - """ - Return information on a role or list of roles. - - To specify multiple roles just add to the arguments, delimit roles with spaces in them using quotation marks. - """ - parsed_roles = [] - failed_roles = [] - - for role_name in roles: - if isinstance(role_name, Role): - # Role conversion has already succeeded - parsed_roles.append(role_name) - continue - - role = utils.find(lambda r: r.name.lower() == role_name.lower(), ctx.guild.roles) - - if not role: - failed_roles.append(role_name) - continue - - parsed_roles.append(role) - - if failed_roles: - await ctx.send(f":x: Could not retrieve the following roles: {', '.join(failed_roles)}") - - for role in parsed_roles: - h, s, v = colorsys.rgb_to_hsv(*role.colour.to_rgb()) - - embed = Embed( - title=f"{role.name} info", - colour=role.colour, - ) - embed.add_field(name="ID", value=role.id, inline=True) - embed.add_field(name="Colour (RGB)", value=f"#{role.colour.value:0>6x}", inline=True) - embed.add_field(name="Colour (HSV)", value=f"{h:.2f} {s:.2f} {v}", inline=True) - embed.add_field(name="Member count", value=len(role.members), inline=True) - embed.add_field(name="Position", value=role.position) - embed.add_field(name="Permission code", value=role.permissions.value, inline=True) - - await ctx.send(embed=embed) - - @command(name="server", aliases=["server_info", "guild", "guild_info"]) - async def server_info(self, ctx: Context) -> None: - """Returns an embed full of server information.""" - created = time_since(ctx.guild.created_at, precision="days") - features = ", ".join(ctx.guild.features) - region = ctx.guild.region - - roles = len(ctx.guild.roles) - member_count = ctx.guild.member_count - channel_counts = self.get_channel_type_counts(ctx.guild) - - # How many of each user status? - statuses = Counter(member.status for member in ctx.guild.members) - embed = Embed(colour=Colour.blurple()) - - # How many staff members and staff channels do we have? - staff_member_count = len(ctx.guild.get_role(constants.Roles.helpers).members) - staff_channel_count = self.get_staff_channel_count(ctx.guild) - - # Because channel_counts lacks leading whitespace, it breaks the dedent if it's inserted directly by the - # f-string. While this is correctly formated by Discord, it makes unit testing difficult. To keep the formatting - # without joining a tuple of strings we can use a Template string to insert the already-formatted channel_counts - # after the dedent is made. - embed.description = Template( - textwrap.dedent(f""" - **Server information** - Created: {created} - Voice region: {region} - Features: {features} - - **Channel counts** - $channel_counts - Staff channels: {staff_channel_count} - - **Member counts** - Members: {member_count:,} - Staff members: {staff_member_count} - Roles: {roles} - - **Member statuses** - {constants.Emojis.status_online} {statuses[Status.online]:,} - {constants.Emojis.status_idle} {statuses[Status.idle]:,} - {constants.Emojis.status_dnd} {statuses[Status.dnd]:,} - {constants.Emojis.status_offline} {statuses[Status.offline]:,} - """) - ).substitute({"channel_counts": channel_counts}) - embed.set_thumbnail(url=ctx.guild.icon_url) - - await ctx.send(embed=embed) - - @command(name="user", aliases=["user_info", "member", "member_info"]) - async def user_info(self, ctx: Context, user: Member = None) -> None: - """Returns info about a user.""" - if user is None: - user = ctx.author - - # Do a role check if this is being executed on someone other than the caller - elif user != ctx.author and not with_role_check(ctx, *constants.MODERATION_ROLES): - await ctx.send("You may not use this command on users other than yourself.") - return - - # Non-staff may only do this in #bot-commands - if not with_role_check(ctx, *constants.STAFF_ROLES): - if not ctx.channel.id == constants.Channels.bot_commands: - raise InWhitelistCheckFailure(constants.Channels.bot_commands) - - embed = await self.create_user_embed(ctx, user) - - await ctx.send(embed=embed) - - async def create_user_embed(self, ctx: Context, user: Member) -> Embed: - """Creates an embed containing information on the `user`.""" - created = time_since(user.created_at, max_units=3) - - # Custom status - custom_status = '' - for activity in user.activities: - # Check activity.state for None value if user has a custom status set - # This guards against a custom status with an emoji but no text, which will cause - # escape_markdown to raise an exception - # This can be reworked after a move to d.py 1.3.0+, which adds a CustomActivity class - if activity.name == 'Custom Status' and activity.state: - state = escape_markdown(activity.state) - custom_status = f'Status: {state}\n' - - name = str(user) - if user.nick: - name = f"{user.nick} ({name})" - - joined = time_since(user.joined_at, max_units=3) - roles = ", ".join(role.mention for role in user.roles[1:]) - - description = [ - textwrap.dedent(f""" - **User Information** - Created: {created} - Profile: {user.mention} - ID: {user.id} - {custom_status} - **Member Information** - Joined: {joined} - Roles: {roles or None} - """).strip() - ] - - # Show more verbose output in moderation channels for infractions and nominations - if ctx.channel.id in constants.MODERATION_CHANNELS: - description.append(await self.expanded_user_infraction_counts(user)) - description.append(await self.user_nomination_counts(user)) - else: - description.append(await self.basic_user_infraction_counts(user)) - - # Let's build the embed now - embed = Embed( - title=name, - description="\n\n".join(description) - ) - - embed.set_thumbnail(url=user.avatar_url_as(static_format="png")) - embed.colour = user.top_role.colour if roles else Colour.blurple() - - return embed - - async def basic_user_infraction_counts(self, member: Member) -> str: - """Gets the total and active infraction counts for the given `member`.""" - infractions = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'hidden': 'False', - 'user__id': str(member.id) - } - ) - - total_infractions = len(infractions) - active_infractions = sum(infraction['active'] for infraction in infractions) - - infraction_output = f"**Infractions**\nTotal: {total_infractions}\nActive: {active_infractions}" - - return infraction_output - - async def expanded_user_infraction_counts(self, member: Member) -> str: - """ - Gets expanded infraction counts for the given `member`. - - The counts will be split by infraction type and the number of active infractions for each type will indicated - in the output as well. - """ - infractions = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'user__id': str(member.id) - } - ) - - infraction_output = ["**Infractions**"] - if not infractions: - infraction_output.append("This user has never received an infraction.") - else: - # Count infractions split by `type` and `active` status for this user - infraction_types = set() - infraction_counter = defaultdict(int) - for infraction in infractions: - infraction_type = infraction["type"] - infraction_active = 'active' if infraction["active"] else 'inactive' - - infraction_types.add(infraction_type) - infraction_counter[f"{infraction_active} {infraction_type}"] += 1 - - # Format the output of the infraction counts - for infraction_type in sorted(infraction_types): - active_count = infraction_counter[f"active {infraction_type}"] - total_count = active_count + infraction_counter[f"inactive {infraction_type}"] - - line = f"{infraction_type.capitalize()}s: {total_count}" - if active_count: - line += f" ({active_count} active)" - - infraction_output.append(line) - - return "\n".join(infraction_output) - - async def user_nomination_counts(self, member: Member) -> str: - """Gets the active and historical nomination counts for the given `member`.""" - nominations = await self.bot.api_client.get( - 'bot/nominations', - params={ - 'user__id': str(member.id) - } - ) - - output = ["**Nominations**"] - - if not nominations: - output.append("This user has never been nominated.") - else: - count = len(nominations) - is_currently_nominated = any(nomination["active"] for nomination in nominations) - nomination_noun = "nomination" if count == 1 else "nominations" - - if is_currently_nominated: - output.append(f"This user is **currently** nominated ({count} {nomination_noun} in total).") - else: - output.append(f"This user has {count} historical {nomination_noun}, but is currently not nominated.") - - return "\n".join(output) - - def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str: - """Format a mapping to be readable to a human.""" - # sorting is technically superfluous but nice if you want to look for a specific field - fields = sorted(mapping.items(), key=lambda item: item[0]) - - if field_width is None: - field_width = len(max(mapping.keys(), key=len)) - - out = '' - - for key, val in fields: - if isinstance(val, dict): - # if we have dicts inside dicts we want to apply the same treatment to the inner dictionaries - inner_width = int(field_width * 1.6) - val = '\n' + self.format_fields(val, field_width=inner_width) - - elif isinstance(val, str): - # split up text since it might be long - text = textwrap.fill(val, width=100, replace_whitespace=False) - - # indent it, I guess you could do this with `wrap` and `join` but this is nicer - val = textwrap.indent(text, ' ' * (field_width + len(': '))) - - # the first line is already indented so we `str.lstrip` it - val = val.lstrip() - - if key == 'color': - # makes the base 10 representation of a hex number readable to humans - val = hex(val) - - out += '{0:>{width}}: {1}\n'.format(key, val, width=field_width) - - # remove trailing whitespace - return out.rstrip() - - @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES) - @group(invoke_without_command=True) - @in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_ROLES) - async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None: - """Shows information about the raw API response.""" - # I *guess* it could be deleted right as the command is invoked but I felt like it wasn't worth handling - # doing this extra request is also much easier than trying to convert everything back into a dictionary again - raw_data = await ctx.bot.http.get_message(message.channel.id, message.id) - - paginator = Paginator() - - def add_content(title: str, content: str) -> None: - paginator.add_line(f'== {title} ==\n') - # replace backticks as it breaks out of code blocks. Spaces seemed to be the most reasonable solution. - # we hope it's not close to 2000 - paginator.add_line(content.replace('```', '`` `')) - paginator.close_page() - - if message.content: - add_content('Raw message', message.content) - - transformer = pprint.pformat if json else self.format_fields - for field_name in ('embeds', 'attachments'): - data = raw_data[field_name] - - if not data: - continue - - total = len(data) - for current, item in enumerate(data, start=1): - title = f'Raw {field_name} ({current}/{total})' - add_content(title, transformer(item)) - - for page in paginator.pages: - await ctx.send(page) - - @raw.command() - async def json(self, ctx: Context, message: Message) -> None: - """Shows information about the raw API response in a copy-pasteable Python format.""" - await ctx.invoke(self.raw, message=message, json=True) - - -def setup(bot: Bot) -> None: - """Load the Information cog.""" - bot.add_cog(Information(bot)) diff --git a/bot/cogs/info/python_news.py b/bot/cogs/info/python_news.py deleted file mode 100644 index 0ab5738a4..000000000 --- a/bot/cogs/info/python_news.py +++ /dev/null @@ -1,232 +0,0 @@ -import logging -import typing as t -from datetime import date, datetime - -import discord -import feedparser -from bs4 import BeautifulSoup -from discord.ext.commands import Cog -from discord.ext.tasks import loop - -from bot import constants -from bot.bot import Bot -from bot.utils.webhooks import send_webhook - -PEPS_RSS_URL = "https://www.python.org/dev/peps/peps.rss/" - -RECENT_THREADS_TEMPLATE = "https://mail.python.org/archives/list/{name}@python.org/recent-threads" -THREAD_TEMPLATE_URL = "https://mail.python.org/archives/api/list/{name}@python.org/thread/{id}/" -MAILMAN_PROFILE_URL = "https://mail.python.org/archives/users/{id}/" -THREAD_URL = "https://mail.python.org/archives/list/{list}@python.org/thread/{id}/" - -AVATAR_URL = "https://www.python.org/static/opengraph-icon-200x200.png" - -log = logging.getLogger(__name__) - - -class PythonNews(Cog): - """Post new PEPs and Python News to `#python-news`.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.webhook_names = {} - self.webhook: t.Optional[discord.Webhook] = None - - self.bot.loop.create_task(self.get_webhook_names()) - self.bot.loop.create_task(self.get_webhook_and_channel()) - - async def start_tasks(self) -> None: - """Start the tasks for fetching new PEPs and mailing list messages.""" - self.fetch_new_media.start() - - @loop(minutes=20) - async def fetch_new_media(self) -> None: - """Fetch new mailing list messages and then new PEPs.""" - await self.post_maillist_news() - await self.post_pep_news() - - async def sync_maillists(self) -> None: - """Sync currently in-use maillists with API.""" - # Wait until guild is available to avoid running before everything is ready - await self.bot.wait_until_guild_available() - - response = await self.bot.api_client.get("bot/bot-settings/news") - for mail in constants.PythonNews.mail_lists: - if mail not in response["data"]: - response["data"][mail] = [] - - # Because we are handling PEPs differently, we don't include it to mail lists - if "pep" not in response["data"]: - response["data"]["pep"] = [] - - await self.bot.api_client.put("bot/bot-settings/news", json=response) - - async def get_webhook_names(self) -> None: - """Get webhook author names from maillist API.""" - await self.bot.wait_until_guild_available() - - async with self.bot.http_session.get("https://mail.python.org/archives/api/lists") as resp: - lists = await resp.json() - - for mail in lists: - if mail["name"].split("@")[0] in constants.PythonNews.mail_lists: - self.webhook_names[mail["name"].split("@")[0]] = mail["display_name"] - - async def post_pep_news(self) -> None: - """Fetch new PEPs and when they don't have announcement in #python-news, create it.""" - # Wait until everything is ready and http_session available - await self.bot.wait_until_guild_available() - await self.sync_maillists() - - async with self.bot.http_session.get(PEPS_RSS_URL) as resp: - data = feedparser.parse(await resp.text("utf-8")) - - news_listing = await self.bot.api_client.get("bot/bot-settings/news") - payload = news_listing.copy() - pep_numbers = news_listing["data"]["pep"] - - # Reverse entries to send oldest first - data["entries"].reverse() - for new in data["entries"]: - try: - new_datetime = datetime.strptime(new["published"], "%a, %d %b %Y %X %Z") - except ValueError: - log.warning(f"Wrong datetime format passed in PEP new: {new['published']}") - continue - pep_nr = new["title"].split(":")[0].split()[1] - if ( - pep_nr in pep_numbers - or new_datetime.date() < date.today() - ): - continue - - # Build an embed and send a webhook - embed = discord.Embed( - title=new["title"], - description=new["summary"], - timestamp=new_datetime, - url=new["link"], - colour=constants.Colours.soft_green - ) - embed.set_footer(text=data["feed"]["title"], icon_url=AVATAR_URL) - msg = await send_webhook( - webhook=self.webhook, - username=data["feed"]["title"], - embed=embed, - avatar_url=AVATAR_URL, - wait=True, - ) - payload["data"]["pep"].append(pep_nr) - - # Increase overall PEP new stat - self.bot.stats.incr("python_news.posted.pep") - - if msg.channel.is_news(): - log.trace("Publishing PEP annnouncement because it was in a news channel") - await msg.publish() - - # Apply new sent news to DB to avoid duplicate sending - await self.bot.api_client.put("bot/bot-settings/news", json=payload) - - async def post_maillist_news(self) -> None: - """Send new maillist threads to #python-news that is listed in configuration.""" - await self.bot.wait_until_guild_available() - await self.sync_maillists() - existing_news = await self.bot.api_client.get("bot/bot-settings/news") - payload = existing_news.copy() - - for maillist in constants.PythonNews.mail_lists: - async with self.bot.http_session.get(RECENT_THREADS_TEMPLATE.format(name=maillist)) as resp: - recents = BeautifulSoup(await resp.text(), features="lxml") - - # When a

element is present in the response then the mailing list - # has not had any activity during the current month, so therefore it - # can be ignored. - if recents.p: - continue - - for thread in recents.html.body.div.find_all("a", href=True): - # We want only these threads that have identifiers - if "latest" in thread["href"]: - continue - - thread_information, email_information = await self.get_thread_and_first_mail( - maillist, thread["href"].split("/")[-2] - ) - - try: - new_date = datetime.strptime(email_information["date"], "%Y-%m-%dT%X%z") - except ValueError: - log.warning(f"Invalid datetime from Thread email: {email_information['date']}") - continue - - if ( - thread_information["thread_id"] in existing_news["data"][maillist] - or 'Re: ' in thread_information["subject"] - or new_date.date() < date.today() - ): - continue - - content = email_information["content"] - link = THREAD_URL.format(id=thread["href"].split("/")[-2], list=maillist) - - # Build an embed and send a message to the webhook - embed = discord.Embed( - title=thread_information["subject"], - description=content[:500] + f"... [continue reading]({link})" if len(content) > 500 else content, - timestamp=new_date, - url=link, - colour=constants.Colours.soft_green - ) - embed.set_author( - name=f"{email_information['sender_name']} ({email_information['sender']['address']})", - url=MAILMAN_PROFILE_URL.format(id=email_information["sender"]["mailman_id"]), - ) - embed.set_footer( - text=f"Posted to {self.webhook_names[maillist]}", - icon_url=AVATAR_URL, - ) - msg = await send_webhook( - webhook=self.webhook, - username=self.webhook_names[maillist], - embed=embed, - avatar_url=AVATAR_URL, - wait=True, - ) - payload["data"][maillist].append(thread_information["thread_id"]) - - # Increase this specific maillist counter in stats - self.bot.stats.incr(f"python_news.posted.{maillist.replace('-', '_')}") - - if msg.channel.is_news(): - log.trace("Publishing mailing list message because it was in a news channel") - await msg.publish() - - await self.bot.api_client.put("bot/bot-settings/news", json=payload) - - async def get_thread_and_first_mail(self, maillist: str, thread_identifier: str) -> t.Tuple[t.Any, t.Any]: - """Get mail thread and first mail from mail.python.org based on `maillist` and `thread_identifier`.""" - async with self.bot.http_session.get( - THREAD_TEMPLATE_URL.format(name=maillist, id=thread_identifier) - ) as resp: - thread_information = await resp.json() - - async with self.bot.http_session.get(thread_information["starting_email"]) as resp: - email_information = await resp.json() - return thread_information, email_information - - async def get_webhook_and_channel(self) -> None: - """Storage #python-news channel Webhook and `TextChannel` to `News.webhook` and `channel`.""" - await self.bot.wait_until_guild_available() - self.webhook = await self.bot.fetch_webhook(constants.PythonNews.webhook) - - await self.start_tasks() - - def cog_unload(self) -> None: - """Stop news posting tasks on cog unload.""" - self.fetch_new_media.cancel() - - -def setup(bot: Bot) -> None: - """Add `News` cog.""" - bot.add_cog(PythonNews(bot)) diff --git a/bot/cogs/info/reddit.py b/bot/cogs/info/reddit.py deleted file mode 100644 index d853ab2ea..000000000 --- a/bot/cogs/info/reddit.py +++ /dev/null @@ -1,304 +0,0 @@ -import asyncio -import logging -import random -import textwrap -from collections import namedtuple -from datetime import datetime, timedelta -from typing import List - -from aiohttp import BasicAuth, ClientError -from discord import Colour, Embed, TextChannel -from discord.ext.commands import Cog, Context, group -from discord.ext.tasks import loop - -from bot.bot import Bot -from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES, Webhooks -from bot.converters import Subreddit -from bot.decorators import with_role -from bot.pagination import LinePaginator -from bot.utils.messages import sub_clyde - -log = logging.getLogger(__name__) - -AccessToken = namedtuple("AccessToken", ["token", "expires_at"]) - - -class Reddit(Cog): - """Track subreddit posts and show detailed statistics about them.""" - - HEADERS = {"User-Agent": "python3:python-discord/bot:1.0.0 (by /u/PythonDiscord)"} - URL = "https://www.reddit.com" - OAUTH_URL = "https://oauth.reddit.com" - MAX_RETRIES = 3 - - def __init__(self, bot: Bot): - self.bot = bot - - self.webhook = None - self.access_token = None - self.client_auth = BasicAuth(RedditConfig.client_id, RedditConfig.secret) - - bot.loop.create_task(self.init_reddit_ready()) - self.auto_poster_loop.start() - - def cog_unload(self) -> None: - """Stop the loop task and revoke the access token when the cog is unloaded.""" - self.auto_poster_loop.cancel() - if self.access_token and self.access_token.expires_at > datetime.utcnow(): - asyncio.create_task(self.revoke_access_token()) - - async def init_reddit_ready(self) -> None: - """Sets the reddit webhook when the cog is loaded.""" - await self.bot.wait_until_guild_available() - if not self.webhook: - self.webhook = await self.bot.fetch_webhook(Webhooks.reddit) - - @property - def channel(self) -> TextChannel: - """Get the #reddit channel object from the bot's cache.""" - return self.bot.get_channel(Channels.reddit) - - async def get_access_token(self) -> None: - """ - Get a Reddit API OAuth2 access token and assign it to self.access_token. - - A token is valid for 1 hour. There will be MAX_RETRIES to get a token, after which the cog - will be unloaded and a ClientError raised if retrieval was still unsuccessful. - """ - for i in range(1, self.MAX_RETRIES + 1): - response = await self.bot.http_session.post( - url=f"{self.URL}/api/v1/access_token", - headers=self.HEADERS, - auth=self.client_auth, - data={ - "grant_type": "client_credentials", - "duration": "temporary" - } - ) - - if response.status == 200 and response.content_type == "application/json": - content = await response.json() - expiration = int(content["expires_in"]) - 60 # Subtract 1 minute for leeway. - self.access_token = AccessToken( - token=content["access_token"], - expires_at=datetime.utcnow() + timedelta(seconds=expiration) - ) - - log.debug(f"New token acquired; expires on UTC {self.access_token.expires_at}") - return - else: - log.debug( - f"Failed to get an access token: " - f"status {response.status} & content type {response.content_type}; " - f"retrying ({i}/{self.MAX_RETRIES})" - ) - - await asyncio.sleep(3) - - self.bot.remove_cog(self.qualified_name) - raise ClientError("Authentication with the Reddit API failed. Unloading the cog.") - - async def revoke_access_token(self) -> None: - """ - Revoke the OAuth2 access token for the Reddit API. - - For security reasons, it's good practice to revoke the token when it's no longer being used. - """ - response = await self.bot.http_session.post( - url=f"{self.URL}/api/v1/revoke_token", - headers=self.HEADERS, - auth=self.client_auth, - data={ - "token": self.access_token.token, - "token_type_hint": "access_token" - } - ) - - if response.status == 204 and response.content_type == "application/json": - self.access_token = None - else: - log.warning(f"Unable to revoke access token: status {response.status}.") - - async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> List[dict]: - """A helper method to fetch a certain amount of Reddit posts at a given route.""" - # Reddit's JSON responses only provide 25 posts at most. - if not 25 >= amount > 0: - raise ValueError("Invalid amount of subreddit posts requested.") - - # Renew the token if necessary. - if not self.access_token or self.access_token.expires_at < datetime.utcnow(): - await self.get_access_token() - - url = f"{self.OAUTH_URL}/{route}" - for _ in range(self.MAX_RETRIES): - response = await self.bot.http_session.get( - url=url, - headers={**self.HEADERS, "Authorization": f"bearer {self.access_token.token}"}, - params=params - ) - if response.status == 200 and response.content_type == 'application/json': - # Got appropriate response - process and return. - content = await response.json() - posts = content["data"]["children"] - return posts[:amount] - - await asyncio.sleep(3) - - log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}") - return list() # Failed to get appropriate response within allowed number of retries. - - async def get_top_posts(self, subreddit: Subreddit, time: str = "all", amount: int = 5) -> Embed: - """ - Get the top amount of posts for a given subreddit within a specified timeframe. - - A time of "all" will get posts from all time, "day" will get top daily posts and "week" will get the top - weekly posts. - - The amount should be between 0 and 25 as Reddit's JSON requests only provide 25 posts at most. - """ - embed = Embed(description="") - - posts = await self.fetch_posts( - route=f"{subreddit}/top", - amount=amount, - params={"t": time} - ) - - if not posts: - embed.title = random.choice(ERROR_REPLIES) - embed.colour = Colour.red() - embed.description = ( - "Sorry! We couldn't find any posts from that subreddit. " - "If this problem persists, please let us know." - ) - - return embed - - for post in posts: - data = post["data"] - - text = data["selftext"] - if text: - text = textwrap.shorten(text, width=128, placeholder="...") - text += "\n" # Add newline to separate embed info - - ups = data["ups"] - comments = data["num_comments"] - author = data["author"] - - title = textwrap.shorten(data["title"], width=64, placeholder="...") - link = self.URL + data["permalink"] - - embed.description += ( - f"**[{title}]({link})**\n" - f"{text}" - f"{Emojis.upvotes} {ups} {Emojis.comments} {comments} {Emojis.user} {author}\n\n" - ) - - embed.colour = Colour.blurple() - return embed - - @loop() - async def auto_poster_loop(self) -> None: - """Post the top 5 posts daily, and the top 5 posts weekly.""" - # once we upgrade to d.py 1.3 this can be removed and the loop can use the `time=datetime.time.min` parameter - now = datetime.utcnow() - tomorrow = now + timedelta(days=1) - midnight_tomorrow = tomorrow.replace(hour=0, minute=0, second=0) - seconds_until = (midnight_tomorrow - now).total_seconds() - - await asyncio.sleep(seconds_until) - - await self.bot.wait_until_guild_available() - if not self.webhook: - await self.bot.fetch_webhook(Webhooks.reddit) - - if datetime.utcnow().weekday() == 0: - await self.top_weekly_posts() - # if it's a monday send the top weekly posts - - for subreddit in RedditConfig.subreddits: - top_posts = await self.get_top_posts(subreddit=subreddit, time="day") - username = sub_clyde(f"{subreddit} Top Daily Posts") - message = await self.webhook.send(username=username, embed=top_posts, wait=True) - - if message.channel.is_news(): - await message.publish() - - async def top_weekly_posts(self) -> None: - """Post a summary of the top posts.""" - for subreddit in RedditConfig.subreddits: - # Send and pin the new weekly posts. - top_posts = await self.get_top_posts(subreddit=subreddit, time="week") - username = sub_clyde(f"{subreddit} Top Weekly Posts") - message = await self.webhook.send(wait=True, username=username, embed=top_posts) - - if subreddit.lower() == "r/python": - if not self.channel: - log.warning("Failed to get #reddit channel to remove pins in the weekly loop.") - return - - # Remove the oldest pins so that only 12 remain at most. - pins = await self.channel.pins() - - while len(pins) >= 12: - await pins[-1].unpin() - del pins[-1] - - await message.pin() - - if message.channel.is_news(): - await message.publish() - - @group(name="reddit", invoke_without_command=True) - async def reddit_group(self, ctx: Context) -> None: - """View the top posts from various subreddits.""" - await ctx.send_help(ctx.command) - - @reddit_group.command(name="top") - async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: - """Send the top posts of all time from a given subreddit.""" - async with ctx.typing(): - embed = await self.get_top_posts(subreddit=subreddit, time="all") - - await ctx.send(content=f"Here are the top {subreddit} posts of all time!", embed=embed) - - @reddit_group.command(name="daily") - async def daily_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: - """Send the top posts of today from a given subreddit.""" - async with ctx.typing(): - embed = await self.get_top_posts(subreddit=subreddit, time="day") - - await ctx.send(content=f"Here are today's top {subreddit} posts!", embed=embed) - - @reddit_group.command(name="weekly") - async def weekly_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: - """Send the top posts of this week from a given subreddit.""" - async with ctx.typing(): - embed = await self.get_top_posts(subreddit=subreddit, time="week") - - await ctx.send(content=f"Here are this week's top {subreddit} posts!", embed=embed) - - @with_role(*STAFF_ROLES) - @reddit_group.command(name="subreddits", aliases=("subs",)) - async def subreddits_command(self, ctx: Context) -> None: - """Send a paginated embed of all the subreddits we're relaying.""" - embed = Embed() - embed.title = "Relayed subreddits." - embed.colour = Colour.blurple() - - await LinePaginator.paginate( - RedditConfig.subreddits, - ctx, embed, - footer_text="Use the reddit commands along with these to view their posts.", - empty=False, - max_lines=15 - ) - - -def setup(bot: Bot) -> None: - """Load the Reddit cog.""" - if not RedditConfig.secret or not RedditConfig.client_id: - log.error("Credentials not provided, cog not loaded.") - return - bot.add_cog(Reddit(bot)) diff --git a/bot/cogs/info/site.py b/bot/cogs/info/site.py deleted file mode 100644 index ac29daa1d..000000000 --- a/bot/cogs/info/site.py +++ /dev/null @@ -1,146 +0,0 @@ -import logging - -from discord import Colour, Embed -from discord.ext.commands import Cog, Context, group - -from bot.bot import Bot -from bot.constants import URLs -from bot.pagination import LinePaginator - -log = logging.getLogger(__name__) - -PAGES_URL = f"{URLs.site_schema}{URLs.site}/pages" - - -class Site(Cog): - """Commands for linking to different parts of the site.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @group(name="site", aliases=("s",), invoke_without_command=True) - async def site_group(self, ctx: Context) -> None: - """Commands for getting info about our website.""" - await ctx.send_help(ctx.command) - - @site_group.command(name="home", aliases=("about",)) - async def site_main(self, ctx: Context) -> None: - """Info about the website itself.""" - url = f"{URLs.site_schema}{URLs.site}/" - - embed = Embed(title="Python Discord website") - embed.set_footer(text=url) - embed.colour = Colour.blurple() - embed.description = ( - f"[Our official website]({url}) is an open-source community project " - "created with Python and Django. It contains information about the server " - "itself, lets you sign up for upcoming events, has its own wiki, contains " - "a list of valuable learning resources, and much more." - ) - - await ctx.send(embed=embed) - - @site_group.command(name="resources") - async def site_resources(self, ctx: Context) -> None: - """Info about the site's Resources page.""" - learning_url = f"{PAGES_URL}/resources" - - embed = Embed(title="Resources") - embed.set_footer(text=f"{learning_url}") - embed.colour = Colour.blurple() - embed.description = ( - f"The [Resources page]({learning_url}) on our website contains a " - "list of hand-selected learning resources that we regularly recommend " - f"to both beginners and experts." - ) - - await ctx.send(embed=embed) - - @site_group.command(name="tools") - async def site_tools(self, ctx: Context) -> None: - """Info about the site's Tools page.""" - tools_url = f"{PAGES_URL}/resources/tools" - - embed = Embed(title="Tools") - embed.set_footer(text=f"{tools_url}") - embed.colour = Colour.blurple() - embed.description = ( - f"The [Tools page]({tools_url}) on our website contains a " - f"couple of the most popular tools for programming in Python." - ) - - await ctx.send(embed=embed) - - @site_group.command(name="help") - async def site_help(self, ctx: Context) -> None: - """Info about the site's Getting Help page.""" - url = f"{PAGES_URL}/resources/guides/asking-good-questions" - - embed = Embed(title="Asking Good Questions") - embed.set_footer(text=url) - embed.colour = Colour.blurple() - embed.description = ( - "Asking the right question about something that's new to you can sometimes be tricky. " - f"To help with this, we've created a [guide to asking good questions]({url}) on our website. " - "It contains everything you need to get the very best help from our community." - ) - - await ctx.send(embed=embed) - - @site_group.command(name="faq") - async def site_faq(self, ctx: Context) -> None: - """Info about the site's FAQ page.""" - url = f"{PAGES_URL}/frequently-asked-questions" - - embed = Embed(title="FAQ") - embed.set_footer(text=url) - embed.colour = Colour.blurple() - embed.description = ( - "As the largest Python community on Discord, we get hundreds of questions every day. " - "Many of these questions have been asked before. We've compiled a list of the most " - "frequently asked questions along with their answers, which can be found on " - f"our [FAQ page]({url})." - ) - - await ctx.send(embed=embed) - - @site_group.command(aliases=['r', 'rule'], name='rules') - async def site_rules(self, ctx: Context, *rules: int) -> None: - """Provides a link to all rules or, if specified, displays specific rule(s).""" - rules_embed = Embed(title='Rules', color=Colour.blurple()) - rules_embed.url = f"{PAGES_URL}/rules" - - if not rules: - # Rules were not submitted. Return the default description. - rules_embed.description = ( - "The rules and guidelines that apply to this community can be found on" - f" our [rules page]({PAGES_URL}/rules). We expect" - " all members of the community to have read and understood these." - ) - - await ctx.send(embed=rules_embed) - return - - full_rules = await self.bot.api_client.get('rules', params={'link_format': 'md'}) - invalid_indices = tuple( - pick - for pick in rules - if pick < 1 or pick > len(full_rules) - ) - - if invalid_indices: - indices = ', '.join(map(str, invalid_indices)) - await ctx.send(f":x: Invalid rule indices: {indices}") - return - - for rule in rules: - self.bot.stats.incr(f"rule_uses.{rule}") - - final_rules = tuple(f"**{pick}.** {full_rules[pick - 1]}" for pick in rules) - - await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3) - - -def setup(bot: Bot) -> None: - """Load the Site cog.""" - bot.add_cog(Site(bot)) diff --git a/bot/cogs/info/source.py b/bot/cogs/info/source.py deleted file mode 100644 index 205e0ba81..000000000 --- a/bot/cogs/info/source.py +++ /dev/null @@ -1,141 +0,0 @@ -import inspect -from pathlib import Path -from typing import Optional, Tuple, Union - -from discord import Embed -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import URLs - -SourceType = Union[commands.HelpCommand, commands.Command, commands.Cog, str, commands.ExtensionNotLoaded] - - -class SourceConverter(commands.Converter): - """Convert an argument into a help command, tag, command, or cog.""" - - async def convert(self, ctx: commands.Context, argument: str) -> SourceType: - """Convert argument into source object.""" - if argument.lower().startswith("help"): - return ctx.bot.help_command - - cog = ctx.bot.get_cog(argument) - if cog: - return cog - - cmd = ctx.bot.get_command(argument) - if cmd: - return cmd - - tags_cog = ctx.bot.get_cog("Tags") - show_tag = True - - if not tags_cog: - show_tag = False - elif argument.lower() in tags_cog._cache: - return argument.lower() - - raise commands.BadArgument( - f"Unable to convert `{argument}` to valid command{', tag,' if show_tag else ''} or Cog." - ) - - -class BotSource(commands.Cog): - """Displays information about the bot's source code.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @commands.command(name="source", aliases=("src",)) - async def source_command(self, ctx: commands.Context, *, source_item: SourceConverter = None) -> None: - """Display information and a GitHub link to the source code of a command, tag, or cog.""" - if not source_item: - embed = Embed(title="Bot's GitHub Repository") - embed.add_field(name="Repository", value=f"[Go to GitHub]({URLs.github_bot_repo})") - embed.set_thumbnail(url="https://avatars1.githubusercontent.com/u/9919") - await ctx.send(embed=embed) - return - - embed = await self.build_embed(source_item) - await ctx.send(embed=embed) - - def get_source_link(self, source_item: SourceType) -> Tuple[str, str, Optional[int]]: - """ - Build GitHub link of source item, return this link, file location and first line number. - - Raise BadArgument if `source_item` is a dynamically-created object (e.g. via internal eval). - """ - if isinstance(source_item, commands.Command): - if source_item.cog_name == "Alias": - cmd_name = source_item.callback.__name__.replace("_alias", "") - cmd = self.bot.get_command(cmd_name.replace("_", " ")) - src = cmd.callback.__code__ - filename = src.co_filename - else: - src = source_item.callback.__code__ - filename = src.co_filename - elif isinstance(source_item, str): - tags_cog = self.bot.get_cog("Tags") - filename = tags_cog._cache[source_item]["location"] - else: - src = type(source_item) - try: - filename = inspect.getsourcefile(src) - except TypeError: - raise commands.BadArgument("Cannot get source for a dynamically-created object.") - - if not isinstance(source_item, str): - try: - lines, first_line_no = inspect.getsourcelines(src) - except OSError: - raise commands.BadArgument("Cannot get source for a dynamically-created object.") - - lines_extension = f"#L{first_line_no}-L{first_line_no+len(lines)-1}" - else: - first_line_no = None - lines_extension = "" - - # Handle tag file location differently than others to avoid errors in some cases - if not first_line_no: - file_location = Path(filename).relative_to("/bot/") - else: - file_location = Path(filename).relative_to(Path.cwd()).as_posix() - - url = f"{URLs.github_bot_repo}/blob/master/{file_location}{lines_extension}" - - return url, file_location, first_line_no or None - - async def build_embed(self, source_object: SourceType) -> Optional[Embed]: - """Build embed based on source object.""" - url, location, first_line = self.get_source_link(source_object) - - if isinstance(source_object, commands.HelpCommand): - title = "Help Command" - description = source_object.__doc__.splitlines()[1] - elif isinstance(source_object, commands.Command): - if source_object.cog_name == "Alias": - cmd_name = source_object.callback.__name__.replace("_alias", "") - cmd = self.bot.get_command(cmd_name.replace("_", " ")) - description = cmd.short_doc - else: - description = source_object.short_doc - - title = f"Command: {source_object.qualified_name}" - elif isinstance(source_object, str): - title = f"Tag: {source_object}" - description = "" - else: - title = f"Cog: {source_object.qualified_name}" - description = source_object.description.splitlines()[0] - - embed = Embed(title=title, description=description) - embed.add_field(name="Source Code", value=f"[Go to GitHub]({url})") - line_text = f":{first_line}" if first_line else "" - embed.set_footer(text=f"{location}{line_text}") - - return embed - - -def setup(bot: Bot) -> None: - """Load the BotSource cog.""" - bot.add_cog(BotSource(bot)) diff --git a/bot/cogs/info/stats.py b/bot/cogs/info/stats.py deleted file mode 100644 index d42f55466..000000000 --- a/bot/cogs/info/stats.py +++ /dev/null @@ -1,129 +0,0 @@ -import string -from datetime import datetime - -from discord import Member, Message, Status -from discord.ext.commands import Cog, Context -from discord.ext.tasks import loop - -from bot.bot import Bot -from bot.constants import Categories, Channels, Guild, Stats as StatConf - - -CHANNEL_NAME_OVERRIDES = { - Channels.off_topic_0: "off_topic_0", - Channels.off_topic_1: "off_topic_1", - Channels.off_topic_2: "off_topic_2", - Channels.staff_lounge: "staff_lounge" -} - -ALLOWED_CHARS = string.ascii_letters + string.digits + "_" - - -class Stats(Cog): - """A cog which provides a way to hook onto Discord events and forward to stats.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.last_presence_update = None - self.update_guild_boost.start() - - @Cog.listener() - async def on_message(self, message: Message) -> None: - """Report message events in the server to statsd.""" - if message.guild is None: - return - - if message.guild.id != Guild.id: - return - - cat = getattr(message.channel, "category", None) - if cat is not None and cat.id == Categories.modmail: - if message.channel.id != Channels.incidents: - # Do not report modmail channels to stats, there are too many - # of them for interesting statistics to be drawn out of this. - return - - reformatted_name = message.channel.name.replace('-', '_') - - if CHANNEL_NAME_OVERRIDES.get(message.channel.id): - reformatted_name = CHANNEL_NAME_OVERRIDES.get(message.channel.id) - - reformatted_name = "".join(char for char in reformatted_name if char in ALLOWED_CHARS) - - stat_name = f"channels.{reformatted_name}" - self.bot.stats.incr(stat_name) - - # Increment the total message count - self.bot.stats.incr("messages") - - @Cog.listener() - async def on_command_completion(self, ctx: Context) -> None: - """Report completed commands to statsd.""" - command_name = ctx.command.qualified_name.replace(" ", "_") - - self.bot.stats.incr(f"commands.{command_name}") - - @Cog.listener() - async def on_member_join(self, member: Member) -> None: - """Update member count stat on member join.""" - if member.guild.id != Guild.id: - return - - self.bot.stats.gauge("guild.total_members", len(member.guild.members)) - - @Cog.listener() - async def on_member_leave(self, member: Member) -> None: - """Update member count stat on member leave.""" - if member.guild.id != Guild.id: - return - - self.bot.stats.gauge("guild.total_members", len(member.guild.members)) - - @Cog.listener() - async def on_member_update(self, _before: Member, after: Member) -> None: - """Update presence estimates on member update.""" - if after.guild.id != Guild.id: - return - - if self.last_presence_update: - if (datetime.now() - self.last_presence_update).seconds < StatConf.presence_update_timeout: - return - - self.last_presence_update = datetime.now() - - online = 0 - idle = 0 - dnd = 0 - offline = 0 - - for member in after.guild.members: - if member.status is Status.online: - online += 1 - elif member.status is Status.dnd: - dnd += 1 - elif member.status is Status.idle: - idle += 1 - elif member.status is Status.offline: - offline += 1 - - self.bot.stats.gauge("guild.status.online", online) - self.bot.stats.gauge("guild.status.idle", idle) - self.bot.stats.gauge("guild.status.do_not_disturb", dnd) - self.bot.stats.gauge("guild.status.offline", offline) - - @loop(hours=1) - async def update_guild_boost(self) -> None: - """Post the server boost level and tier every hour.""" - await self.bot.wait_until_guild_available() - g = self.bot.get_guild(Guild.id) - self.bot.stats.gauge("boost.amount", g.premium_subscription_count) - self.bot.stats.gauge("boost.tier", g.premium_tier) - - def cog_unload(self) -> None: - """Stop the boost statistic task on unload of the Cog.""" - self.update_guild_boost.stop() - - -def setup(bot: Bot) -> None: - """Load the stats cog.""" - bot.add_cog(Stats(bot)) diff --git a/bot/cogs/info/tags.py b/bot/cogs/info/tags.py deleted file mode 100644 index 3d76c5c08..000000000 --- a/bot/cogs/info/tags.py +++ /dev/null @@ -1,277 +0,0 @@ -import logging -import re -import time -from pathlib import Path -from typing import Callable, Dict, Iterable, List, Optional - -from discord import Colour, Embed, Member -from discord.ext.commands import Cog, Context, group - -from bot import constants -from bot.bot import Bot -from bot.converters import TagNameConverter -from bot.pagination import LinePaginator -from bot.utils.messages import wait_for_deletion - -log = logging.getLogger(__name__) - -TEST_CHANNELS = ( - constants.Channels.bot_commands, - constants.Channels.helpers -) - -REGEX_NON_ALPHABET = re.compile(r"[^a-z]", re.MULTILINE & re.IGNORECASE) -FOOTER_TEXT = f"To show a tag, type {constants.Bot.prefix}tags ." - - -class Tags(Cog): - """Save new tags and fetch existing tags.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.tag_cooldowns = {} - self._cache = self.get_tags() - - @staticmethod - def get_tags() -> dict: - """Get all tags.""" - cache = {} - - base_path = Path("bot", "resources", "tags") - for file in base_path.glob("**/*"): - if file.is_file(): - tag_title = file.stem - tag = { - "title": tag_title, - "embed": { - "description": file.read_text(encoding="utf8"), - }, - "restricted_to": "developers", - "location": f"/bot/{file}" - } - - # Convert to a list to allow negative indexing. - parents = list(file.relative_to(base_path).parents) - if len(parents) > 1: - # -1 would be '.' hence -2 is used as the index. - tag["restricted_to"] = parents[-2].name - - cache[tag_title] = tag - - return cache - - @staticmethod - def check_accessibility(user: Member, tag: dict) -> bool: - """Check if user can access a tag.""" - return tag["restricted_to"].lower() in [role.name.lower() for role in user.roles] - - @staticmethod - def _fuzzy_search(search: str, target: str) -> float: - """A simple scoring algorithm based on how many letters are found / total, with order in mind.""" - current, index = 0, 0 - _search = REGEX_NON_ALPHABET.sub('', search.lower()) - _targets = iter(REGEX_NON_ALPHABET.split(target.lower())) - _target = next(_targets) - try: - while True: - while index < len(_target) and _search[current] == _target[index]: - current += 1 - index += 1 - index, _target = 0, next(_targets) - except (StopIteration, IndexError): - pass - return current / len(_search) * 100 - - def _get_suggestions(self, tag_name: str, thresholds: Optional[List[int]] = None) -> List[str]: - """Return a list of suggested tags.""" - scores: Dict[str, int] = { - tag_title: Tags._fuzzy_search(tag_name, tag['title']) - for tag_title, tag in self._cache.items() - } - - thresholds = thresholds or [100, 90, 80, 70, 60] - - for threshold in thresholds: - suggestions = [ - self._cache[tag_title] - for tag_title, matching_score in scores.items() - if matching_score >= threshold - ] - if suggestions: - return suggestions - - return [] - - def _get_tag(self, tag_name: str) -> list: - """Get a specific tag.""" - found = [self._cache.get(tag_name.lower(), None)] - if not found[0]: - return self._get_suggestions(tag_name) - return found - - def _get_tags_via_content(self, check: Callable[[Iterable], bool], keywords: str, user: Member) -> list: - """ - Search for tags via contents. - - `predicate` will be the built-in any, all, or a custom callable. Must return a bool. - """ - keywords_processed: List[str] = [] - for keyword in keywords.split(','): - keyword_sanitized = keyword.strip().casefold() - if not keyword_sanitized: - # this happens when there are leading / trailing / consecutive comma. - continue - keywords_processed.append(keyword_sanitized) - - if not keywords_processed: - # after sanitizing, we can end up with an empty list, for example when keywords is ',' - # in that case, we simply want to search for such keywords directly instead. - keywords_processed = [keywords] - - matching_tags = [] - for tag in self._cache.values(): - matches = (query in tag['embed']['description'].casefold() for query in keywords_processed) - if self.check_accessibility(user, tag) and check(matches): - matching_tags.append(tag) - - return matching_tags - - async def _send_matching_tags(self, ctx: Context, keywords: str, matching_tags: list) -> None: - """Send the result of matching tags to user.""" - if not matching_tags: - pass - elif len(matching_tags) == 1: - await ctx.send(embed=Embed().from_dict(matching_tags[0]['embed'])) - else: - is_plural = keywords.strip().count(' ') > 0 or keywords.strip().count(',') > 0 - embed = Embed( - title=f"Here are the tags containing the given keyword{'s' * is_plural}:", - description='\n'.join(tag['title'] for tag in matching_tags[:10]) - ) - await LinePaginator.paginate( - sorted(f"**»** {tag['title']}" for tag in matching_tags), - ctx, - embed, - footer_text=FOOTER_TEXT, - empty=False, - max_lines=15 - ) - - @group(name='tags', aliases=('tag', 't'), invoke_without_command=True) - async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: - """Show all known tags, a single tag, or run a subcommand.""" - await ctx.invoke(self.get_command, tag_name=tag_name) - - @tags_group.group(name='search', invoke_without_command=True) - async def search_tag_content(self, ctx: Context, *, keywords: str) -> None: - """ - Search inside tags' contents for tags. Allow searching for multiple keywords separated by comma. - - Only search for tags that has ALL the keywords. - """ - matching_tags = self._get_tags_via_content(all, keywords, ctx.author) - await self._send_matching_tags(ctx, keywords, matching_tags) - - @search_tag_content.command(name='any') - async def search_tag_content_any_keyword(self, ctx: Context, *, keywords: Optional[str] = 'any') -> None: - """ - Search inside tags' contents for tags. Allow searching for multiple keywords separated by comma. - - Search for tags that has ANY of the keywords. - """ - matching_tags = self._get_tags_via_content(any, keywords or 'any', ctx.author) - await self._send_matching_tags(ctx, keywords, matching_tags) - - @tags_group.command(name='get', aliases=('show', 'g')) - async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: - """Get a specified tag, or a list of all tags if no tag is specified.""" - - def _command_on_cooldown(tag_name: str) -> bool: - """ - Check if the command is currently on cooldown, on a per-tag, per-channel basis. - - The cooldown duration is set in constants.py. - """ - now = time.time() - - cooldown_conditions = ( - tag_name - and tag_name in self.tag_cooldowns - and (now - self.tag_cooldowns[tag_name]["time"]) < constants.Cooldowns.tags - and self.tag_cooldowns[tag_name]["channel"] == ctx.channel.id - ) - - if cooldown_conditions: - return True - return False - - if _command_on_cooldown(tag_name): - time_elapsed = time.time() - self.tag_cooldowns[tag_name]["time"] - time_left = constants.Cooldowns.tags - time_elapsed - log.info( - f"{ctx.author} tried to get the '{tag_name}' tag, but the tag is on cooldown. " - f"Cooldown ends in {time_left:.1f} seconds." - ) - return - - if tag_name is not None: - temp_founds = self._get_tag(tag_name) - - founds = [] - - for found_tag in temp_founds: - if self.check_accessibility(ctx.author, found_tag): - founds.append(found_tag) - - if len(founds) == 1: - tag = founds[0] - if ctx.channel.id not in TEST_CHANNELS: - self.tag_cooldowns[tag_name] = { - "time": time.time(), - "channel": ctx.channel.id - } - - self.bot.stats.incr(f"tags.usages.{tag['title'].replace('-', '_')}") - - await wait_for_deletion( - await ctx.send(embed=Embed.from_dict(tag['embed'])), - [ctx.author.id], - client=self.bot - ) - elif founds and len(tag_name) >= 3: - await wait_for_deletion( - await ctx.send( - embed=Embed( - title='Did you mean ...', - description='\n'.join(tag['title'] for tag in founds[:10]) - ) - ), - [ctx.author.id], - client=self.bot - ) - - else: - tags = self._cache.values() - if not tags: - await ctx.send(embed=Embed( - description="**There are no tags in the database!**", - colour=Colour.red() - )) - else: - embed: Embed = Embed(title="**Current tags**") - await LinePaginator.paginate( - sorted( - f"**»** {tag['title']}" for tag in tags - if self.check_accessibility(ctx.author, tag) - ), - ctx, - embed, - footer_text=FOOTER_TEXT, - empty=False, - max_lines=15 - ) - - -def setup(bot: Bot) -> None: - """Load the Tags cog.""" - bot.add_cog(Tags(bot)) diff --git a/bot/cogs/info/wolfram.py b/bot/cogs/info/wolfram.py deleted file mode 100644 index e6cae3bb8..000000000 --- a/bot/cogs/info/wolfram.py +++ /dev/null @@ -1,280 +0,0 @@ -import logging -from io import BytesIO -from typing import Callable, List, Optional, Tuple -from urllib import parse - -import discord -from dateutil.relativedelta import relativedelta -from discord import Embed -from discord.ext import commands -from discord.ext.commands import BucketType, Cog, Context, check, group - -from bot.bot import Bot -from bot.constants import Colours, STAFF_ROLES, Wolfram -from bot.pagination import ImagePaginator -from bot.utils.time import humanize_delta - -log = logging.getLogger(__name__) - -APPID = Wolfram.key -DEFAULT_OUTPUT_FORMAT = "JSON" -QUERY = "http://api.wolframalpha.com/v2/{request}?{data}" -WOLF_IMAGE = "https://www.symbols.com/gi.php?type=1&id=2886&i=1" - -MAX_PODS = 20 - -# Allows for 10 wolfram calls pr user pr day -usercd = commands.CooldownMapping.from_cooldown(Wolfram.user_limit_day, 60*60*24, BucketType.user) - -# Allows for max api requests / days in month per day for the entire guild (Temporary) -guildcd = commands.CooldownMapping.from_cooldown(Wolfram.guild_limit_day, 60*60*24, BucketType.guild) - - -async def send_embed( - ctx: Context, - message_txt: str, - colour: int = Colours.soft_red, - footer: str = None, - img_url: str = None, - f: discord.File = None -) -> None: - """Generate & send a response embed with Wolfram as the author.""" - embed = Embed(colour=colour) - embed.description = message_txt - embed.set_author(name="Wolfram Alpha", - icon_url=WOLF_IMAGE, - url="https://www.wolframalpha.com/") - if footer: - embed.set_footer(text=footer) - - if img_url: - embed.set_image(url=img_url) - - await ctx.send(embed=embed, file=f) - - -def custom_cooldown(*ignore: List[int]) -> Callable: - """ - Implement per-user and per-guild cooldowns for requests to the Wolfram API. - - A list of roles may be provided to ignore the per-user cooldown - """ - async def predicate(ctx: Context) -> bool: - if ctx.invoked_with == 'help': - # if the invoked command is help we don't want to increase the ratelimits since it's not actually - # invoking the command/making a request, so instead just check if the user/guild are on cooldown. - guild_cooldown = not guildcd.get_bucket(ctx.message).get_tokens() == 0 # if guild is on cooldown - if not any(r.id in ignore for r in ctx.author.roles): # check user bucket if user is not ignored - return guild_cooldown and not usercd.get_bucket(ctx.message).get_tokens() == 0 - return guild_cooldown - - user_bucket = usercd.get_bucket(ctx.message) - - if all(role.id not in ignore for role in ctx.author.roles): - user_rate = user_bucket.update_rate_limit() - - if user_rate: - # Can't use api; cause: member limit - delta = relativedelta(seconds=int(user_rate)) - cooldown = humanize_delta(delta) - message = ( - "You've used up your limit for Wolfram|Alpha requests.\n" - f"Cooldown: {cooldown}" - ) - await send_embed(ctx, message) - return False - - guild_bucket = guildcd.get_bucket(ctx.message) - guild_rate = guild_bucket.update_rate_limit() - - # Repr has a token attribute to read requests left - log.debug(guild_bucket) - - if guild_rate: - # Can't use api; cause: guild limit - message = ( - "The max limit of requests for the server has been reached for today.\n" - f"Cooldown: {int(guild_rate)}" - ) - await send_embed(ctx, message) - return False - - return True - return check(predicate) - - -async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[List[Tuple]]: - """Get the Wolfram API pod pages for the provided query.""" - async with ctx.channel.typing(): - url_str = parse.urlencode({ - "input": query, - "appid": APPID, - "output": DEFAULT_OUTPUT_FORMAT, - "format": "image,plaintext" - }) - request_url = QUERY.format(request="query", data=url_str) - - async with bot.http_session.get(request_url) as response: - json = await response.json(content_type='text/plain') - - result = json["queryresult"] - - if result["error"]: - # API key not set up correctly - if result["error"]["msg"] == "Invalid appid": - message = "Wolfram API key is invalid or missing." - log.warning( - "API key seems to be missing, or invalid when " - f"processing a wolfram request: {url_str}, Response: {json}" - ) - await send_embed(ctx, message) - return - - message = "Something went wrong internally with your request, please notify staff!" - log.warning(f"Something went wrong getting a response from wolfram: {url_str}, Response: {json}") - await send_embed(ctx, message) - return - - if not result["success"]: - message = f"I couldn't find anything for {query}." - await send_embed(ctx, message) - return - - if not result["numpods"]: - message = "Could not find any results." - await send_embed(ctx, message) - return - - pods = result["pods"] - pages = [] - for pod in pods[:MAX_PODS]: - subs = pod.get("subpods") - - for sub in subs: - title = sub.get("title") or sub.get("plaintext") or sub.get("id", "") - img = sub["img"]["src"] - pages.append((title, img)) - return pages - - -class Wolfram(Cog): - """Commands for interacting with the Wolfram|Alpha API.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @group(name="wolfram", aliases=("wolf", "wa"), invoke_without_command=True) - @custom_cooldown(*STAFF_ROLES) - async def wolfram_command(self, ctx: Context, *, query: str) -> None: - """Requests all answers on a single image, sends an image of all related pods.""" - url_str = parse.urlencode({ - "i": query, - "appid": APPID, - }) - query = QUERY.format(request="simple", data=url_str) - - # Give feedback that the bot is working. - async with ctx.channel.typing(): - async with self.bot.http_session.get(query) as response: - status = response.status - image_bytes = await response.read() - - f = discord.File(BytesIO(image_bytes), filename="image.png") - image_url = "attachment://image.png" - - if status == 501: - message = "Failed to get response" - footer = "" - color = Colours.soft_red - elif status == 400: - message = "No input found" - footer = "" - color = Colours.soft_red - elif status == 403: - message = "Wolfram API key is invalid or missing." - footer = "" - color = Colours.soft_red - else: - message = "" - footer = "View original for a bigger picture." - color = Colours.soft_orange - - # Sends a "blank" embed if no request is received, unsure how to fix - await send_embed(ctx, message, color, footer=footer, img_url=image_url, f=f) - - @wolfram_command.command(name="page", aliases=("pa", "p")) - @custom_cooldown(*STAFF_ROLES) - async def wolfram_page_command(self, ctx: Context, *, query: str) -> None: - """ - Requests a drawn image of given query. - - Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. - """ - pages = await get_pod_pages(ctx, self.bot, query) - - if not pages: - return - - embed = Embed() - embed.set_author(name="Wolfram Alpha", - icon_url=WOLF_IMAGE, - url="https://www.wolframalpha.com/") - embed.colour = Colours.soft_orange - - await ImagePaginator.paginate(pages, ctx, embed) - - @wolfram_command.command(name="cut", aliases=("c",)) - @custom_cooldown(*STAFF_ROLES) - async def wolfram_cut_command(self, ctx: Context, *, query: str) -> None: - """ - Requests a drawn image of given query. - - Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. - """ - pages = await get_pod_pages(ctx, self.bot, query) - - if not pages: - return - - if len(pages) >= 2: - page = pages[1] - else: - page = pages[0] - - await send_embed(ctx, page[0], colour=Colours.soft_orange, img_url=page[1]) - - @wolfram_command.command(name="short", aliases=("sh", "s")) - @custom_cooldown(*STAFF_ROLES) - async def wolfram_short_command(self, ctx: Context, *, query: str) -> None: - """Requests an answer to a simple question.""" - url_str = parse.urlencode({ - "i": query, - "appid": APPID, - }) - query = QUERY.format(request="result", data=url_str) - - # Give feedback that the bot is working. - async with ctx.channel.typing(): - async with self.bot.http_session.get(query) as response: - status = response.status - response_text = await response.text() - - if status == 501: - message = "Failed to get response" - color = Colours.soft_red - elif status == 400: - message = "No input found" - color = Colours.soft_red - elif response_text == "Error 1: Invalid appid": - message = "Wolfram API key is invalid or missing." - color = Colours.soft_red - else: - message = response_text - color = Colours.soft_orange - - await send_embed(ctx, message, color) - - -def setup(bot: Bot) -> None: - """Load the Wolfram cog.""" - bot.add_cog(Wolfram(bot)) diff --git a/bot/cogs/moderation/__init__.py b/bot/cogs/moderation/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/bot/cogs/moderation/defcon.py b/bot/cogs/moderation/defcon.py deleted file mode 100644 index e78435a7d..000000000 --- a/bot/cogs/moderation/defcon.py +++ /dev/null @@ -1,258 +0,0 @@ -from __future__ import annotations - -import logging -from collections import namedtuple -from datetime import datetime, timedelta -from enum import Enum - -from discord import Colour, Embed, Member -from discord.ext.commands import Cog, Context, group - -from bot.bot import Bot -from bot.cogs.moderation.modlog import ModLog -from bot.constants import Channels, Colours, Emojis, Event, Icons, Roles -from bot.decorators import with_role - -log = logging.getLogger(__name__) - -REJECTION_MESSAGE = """ -Hi, {user} - Thanks for your interest in our server! - -Due to a current (or detected) cyberattack on our community, we've limited access to the server for new accounts. Since -your account is relatively new, we're unable to provide access to the server at this time. - -Even so, thanks for joining! We're very excited at the possibility of having you here, and we hope that this situation -will be resolved soon. In the meantime, please feel free to peruse the resources on our site at -, and have a nice day! -""" - -BASE_CHANNEL_TOPIC = "Python Discord Defense Mechanism" - - -class Action(Enum): - """Defcon Action.""" - - ActionInfo = namedtuple('LogInfoDetails', ['icon', 'color', 'template']) - - ENABLED = ActionInfo(Icons.defcon_enabled, Colours.soft_green, "**Days:** {days}\n\n") - DISABLED = ActionInfo(Icons.defcon_disabled, Colours.soft_red, "") - UPDATED = ActionInfo(Icons.defcon_updated, Colour.blurple(), "**Days:** {days}\n\n") - - -class Defcon(Cog): - """Time-sensitive server defense mechanisms.""" - - days = None # type: timedelta - enabled = False # type: bool - - def __init__(self, bot: Bot): - self.bot = bot - self.channel = None - self.days = timedelta(days=0) - - self.bot.loop.create_task(self.sync_settings()) - - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - async def sync_settings(self) -> None: - """On cog load, try to synchronize DEFCON settings to the API.""" - await self.bot.wait_until_guild_available() - self.channel = await self.bot.fetch_channel(Channels.defcon) - - try: - response = await self.bot.api_client.get('bot/bot-settings/defcon') - data = response['data'] - - except Exception: # Yikes! - log.exception("Unable to get DEFCON settings!") - await self.bot.get_channel(Channels.dev_log).send( - f"<@&{Roles.admins}> **WARNING**: Unable to get DEFCON settings!" - ) - - else: - if data["enabled"]: - self.enabled = True - self.days = timedelta(days=data["days"]) - log.info(f"DEFCON enabled: {self.days.days} days") - - else: - self.enabled = False - self.days = timedelta(days=0) - log.info("DEFCON disabled") - - await self.update_channel_topic() - - @Cog.listener() - async def on_member_join(self, member: Member) -> None: - """If DEFCON is enabled, check newly joining users to see if they meet the account age threshold.""" - if self.enabled and self.days.days > 0: - now = datetime.utcnow() - - if now - member.created_at < self.days: - log.info(f"Rejecting user {member}: Account is too new and DEFCON is enabled") - - message_sent = False - - try: - await member.send(REJECTION_MESSAGE.format(user=member.mention)) - - message_sent = True - except Exception: - log.exception(f"Unable to send rejection message to user: {member}") - - await member.kick(reason="DEFCON active, user is too new") - self.bot.stats.incr("defcon.leaves") - - message = ( - f"{member} (`{member.id}`) was denied entry because their account is too new." - ) - - if not message_sent: - message = f"{message}\n\nUnable to send rejection message via DM; they probably have DMs disabled." - - await self.mod_log.send_log_message( - Icons.defcon_denied, Colours.soft_red, "Entry denied", - message, member.avatar_url_as(static_format="png") - ) - - @group(name='defcon', aliases=('dc',), invoke_without_command=True) - @with_role(Roles.admins, Roles.owners) - async def defcon_group(self, ctx: Context) -> None: - """Check the DEFCON status or run a subcommand.""" - await ctx.send_help(ctx.command) - - async def _defcon_action(self, ctx: Context, days: int, action: Action) -> None: - """Providing a structured way to do an defcon action.""" - try: - response = await self.bot.api_client.get('bot/bot-settings/defcon') - data = response['data'] - - if "enable_date" in data and action is Action.DISABLED: - enabled = datetime.fromisoformat(data["enable_date"]) - - delta = datetime.now() - enabled - - self.bot.stats.timing("defcon.enabled", delta) - except Exception: - pass - - error = None - try: - await self.bot.api_client.put( - 'bot/bot-settings/defcon', - json={ - 'name': 'defcon', - 'data': { - # TODO: retrieve old days count - 'days': days, - 'enabled': action is not Action.DISABLED, - 'enable_date': datetime.now().isoformat() - } - } - ) - except Exception as err: - log.exception("Unable to update DEFCON settings.") - error = err - finally: - await ctx.send(self.build_defcon_msg(action, error)) - await self.send_defcon_log(action, ctx.author, error) - - self.bot.stats.gauge("defcon.threshold", days) - - @defcon_group.command(name='enable', aliases=('on', 'e')) - @with_role(Roles.admins, Roles.owners) - async def enable_command(self, ctx: Context) -> None: - """ - Enable DEFCON mode. Useful in a pinch, but be sure you know what you're doing! - - Currently, this just adds an account age requirement. Use !defcon days to set how old an account must be, - in days. - """ - self.enabled = True - await self._defcon_action(ctx, days=0, action=Action.ENABLED) - await self.update_channel_topic() - - @defcon_group.command(name='disable', aliases=('off', 'd')) - @with_role(Roles.admins, Roles.owners) - async def disable_command(self, ctx: Context) -> None: - """Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!""" - self.enabled = False - await self._defcon_action(ctx, days=0, action=Action.DISABLED) - await self.update_channel_topic() - - @defcon_group.command(name='status', aliases=('s',)) - @with_role(Roles.admins, Roles.owners) - async def status_command(self, ctx: Context) -> None: - """Check the current status of DEFCON mode.""" - embed = Embed( - colour=Colour.blurple(), title="DEFCON Status", - description=f"**Enabled:** {self.enabled}\n" - f"**Days:** {self.days.days}" - ) - - await ctx.send(embed=embed) - - @defcon_group.command(name='days') - @with_role(Roles.admins, Roles.owners) - async def days_command(self, ctx: Context, days: int) -> None: - """Set how old an account must be to join the server, in days, with DEFCON mode enabled.""" - self.days = timedelta(days=days) - self.enabled = True - await self._defcon_action(ctx, days=days, action=Action.UPDATED) - await self.update_channel_topic() - - async def update_channel_topic(self) -> None: - """Update the #defcon channel topic with the current DEFCON status.""" - if self.enabled: - day_str = "days" if self.days.days > 1 else "day" - new_topic = f"{BASE_CHANNEL_TOPIC}\n(Status: Enabled, Threshold: {self.days.days} {day_str})" - else: - new_topic = f"{BASE_CHANNEL_TOPIC}\n(Status: Disabled)" - - self.mod_log.ignore(Event.guild_channel_update, Channels.defcon) - await self.channel.edit(topic=new_topic) - - def build_defcon_msg(self, action: Action, e: Exception = None) -> str: - """Build in-channel response string for DEFCON action.""" - if action is Action.ENABLED: - msg = f"{Emojis.defcon_enabled} DEFCON enabled.\n\n" - elif action is Action.DISABLED: - msg = f"{Emojis.defcon_disabled} DEFCON disabled.\n\n" - elif action is Action.UPDATED: - msg = ( - f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {self.days.days} " - f"day{'s' if self.days.days > 1 else ''} old to join the server.\n\n" - ) - - if e: - msg += ( - "**There was a problem updating the site** - This setting may be reverted when the bot restarts.\n\n" - f"```py\n{e}\n```" - ) - - return msg - - async def send_defcon_log(self, action: Action, actor: Member, e: Exception = None) -> None: - """Send log message for DEFCON action.""" - info = action.value - log_msg: str = ( - f"**Staffer:** {actor.mention} {actor} (`{actor.id}`)\n" - f"{info.template.format(days=self.days.days)}" - ) - status_msg = f"DEFCON {action.name.lower()}" - - if e: - log_msg += ( - "**There was a problem updating the site** - This setting may be reverted when the bot restarts.\n\n" - f"```py\n{e}\n```" - ) - - await self.mod_log.send_log_message(info.icon, info.color, status_msg, log_msg) - - -def setup(bot: Bot) -> None: - """Load the Defcon cog.""" - bot.add_cog(Defcon(bot)) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py deleted file mode 100644 index e49913552..000000000 --- a/bot/cogs/moderation/incidents.py +++ /dev/null @@ -1,412 +0,0 @@ -import asyncio -import logging -import typing as t -from datetime import datetime -from enum import Enum - -import discord -from discord.ext.commands import Cog - -from bot.bot import Bot -from bot.constants import Channels, Colours, Emojis, Guild, Webhooks -from bot.utils.messages import sub_clyde - -log = logging.getLogger(__name__) - -# Amount of messages for `crawl_task` to process at most on start-up - limited to 50 -# as in practice, there should never be this many messages, and if there are, -# something has likely gone very wrong -CRAWL_LIMIT = 50 - -# Seconds for `crawl_task` to sleep after adding reactions to a message -CRAWL_SLEEP = 2 - - -class Signal(Enum): - """ - Recognized incident status signals. - - This binds emoji to actions. The bot will only react to emoji linked here. - All other signals are seen as invalid. - """ - - ACTIONED = Emojis.incident_actioned - NOT_ACTIONED = Emojis.incident_unactioned - INVESTIGATING = Emojis.incident_investigating - - -# Reactions from non-mod roles will be removed -ALLOWED_ROLES: t.Set[int] = set(Guild.moderation_roles) - -# Message must have all of these emoji to pass the `has_signals` check -ALL_SIGNALS: t.Set[str] = {signal.value for signal in Signal} - -# An embed coupled with an optional file to be dispatched -# If the file is not None, the embed attempts to show it in its body -FileEmbed = t.Tuple[discord.Embed, t.Optional[discord.File]] - - -async def download_file(attachment: discord.Attachment) -> t.Optional[discord.File]: - """ - Download & return `attachment` file. - - If the download fails, the reason is logged and None will be returned. - 404 and 403 errors are only logged at debug level. - """ - log.debug(f"Attempting to download attachment: {attachment.filename}") - try: - return await attachment.to_file() - except (discord.NotFound, discord.Forbidden) as exc: - log.debug(f"Failed to download attachment: {exc}") - except Exception: - log.exception("Failed to download attachment") - - -async def make_embed(incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> FileEmbed: - """ - Create an embed representation of `incident` for the #incidents-archive channel. - - The name & discriminator of `actioned_by` and `outcome` will be presented in the - embed footer. Additionally, the embed is coloured based on `outcome`. - - The author of `incident` is not shown in the embed. It is assumed that this piece - of information will be relayed in other ways, e.g. webhook username. - - As mentions in embeds do not ping, we do not need to use `incident.clean_content`. - - If `incident` contains attachments, the first attachment will be downloaded and - returned alongside the embed. The embed attempts to display the attachment. - Should the download fail, we fallback on linking the `proxy_url`, which should - remain functional for some time after the original message is deleted. - """ - log.trace(f"Creating embed for {incident.id=}") - - if outcome is Signal.ACTIONED: - colour = Colours.soft_green - footer = f"Actioned by {actioned_by}" - else: - colour = Colours.soft_red - footer = f"Rejected by {actioned_by}" - - embed = discord.Embed( - description=incident.content, - timestamp=datetime.utcnow(), - colour=colour, - ) - embed.set_footer(text=footer, icon_url=actioned_by.avatar_url) - - if incident.attachments: - attachment = incident.attachments[0] # User-sent messages can only contain one attachment - file = await download_file(attachment) - - if file is not None: - embed.set_image(url=f"attachment://{attachment.filename}") # Embed displays the attached file - else: - embed.set_author(name="[Failed to relay attachment]", url=attachment.proxy_url) # Embed links the file - else: - file = None - - return embed, file - - -def is_incident(message: discord.Message) -> bool: - """True if `message` qualifies as an incident, False otherwise.""" - conditions = ( - message.channel.id == Channels.incidents, # Message sent in #incidents - not message.author.bot, # Not by a bot - not message.content.startswith("#"), # Doesn't start with a hash - not message.pinned, # And isn't header - ) - return all(conditions) - - -def own_reactions(message: discord.Message) -> t.Set[str]: - """Get the set of reactions placed on `message` by the bot itself.""" - return {str(reaction.emoji) for reaction in message.reactions if reaction.me} - - -def has_signals(message: discord.Message) -> bool: - """True if `message` already has all `Signal` reactions, False otherwise.""" - return ALL_SIGNALS.issubset(own_reactions(message)) - - -async def add_signals(incident: discord.Message) -> None: - """ - Add `Signal` member emoji to `incident` as reactions. - - If the emoji has already been placed on `incident` by the bot, it will be skipped. - """ - existing_reacts = own_reactions(incident) - - for signal_emoji in Signal: - if signal_emoji.value in existing_reacts: # This would not raise, but it is a superfluous API call - log.trace(f"Skipping emoji as it's already been placed: {signal_emoji}") - else: - log.trace(f"Adding reaction: {signal_emoji}") - await incident.add_reaction(signal_emoji.value) - - -class Incidents(Cog): - """ - Automation for the #incidents channel. - - This cog does not provide a command API, it only reacts to the following events. - - On start-up: - * Crawl #incidents and add missing `Signal` emoji where appropriate - * This is to retro-actively add the available options for messages which - were sent while the bot wasn't listening - * Pinned messages and message starting with # do not qualify as incidents - * See: `crawl_incidents` - - On message: - * Add `Signal` member emoji if message qualifies as an incident - * Ignore messages starting with # - * Use this if verbal communication is necessary - * Each such message must be deleted manually once appropriate - * See: `on_message` - - On reaction: - * Remove reaction if not permitted - * User does not have any of the roles in `ALLOWED_ROLES` - * Used emoji is not a `Signal` member - * If `Signal.ACTIONED` or `Signal.NOT_ACTIONED` were chosen, attempt to - relay the incident message to #incidents-archive - * If relay successful, delete original message - * See: `on_raw_reaction_add` - - Please refer to function docstrings for implementation details. - """ - - def __init__(self, bot: Bot) -> None: - """Prepare `event_lock` and schedule `crawl_task` on start-up.""" - self.bot = bot - - self.event_lock = asyncio.Lock() - self.crawl_task = self.bot.loop.create_task(self.crawl_incidents()) - - async def crawl_incidents(self) -> None: - """ - Crawl #incidents and add missing emoji where necessary. - - This is to catch-up should an incident be reported while the bot wasn't listening. - After adding each reaction, we take a short break to avoid drowning in ratelimits. - - Once this task is scheduled, listeners that change messages should await it. - The crawl assumes that the channel history doesn't change as we go over it. - - Behaviour is configured by: `CRAWL_LIMIT`, `CRAWL_SLEEP`. - """ - await self.bot.wait_until_guild_available() - incidents: discord.TextChannel = self.bot.get_channel(Channels.incidents) - - log.debug(f"Crawling messages in #incidents: {CRAWL_LIMIT=}, {CRAWL_SLEEP=}") - async for message in incidents.history(limit=CRAWL_LIMIT): - - if not is_incident(message): - log.trace(f"Skipping message {message.id}: not an incident") - continue - - if has_signals(message): - log.trace(f"Skipping message {message.id}: already has all signals") - continue - - await add_signals(message) - await asyncio.sleep(CRAWL_SLEEP) - - log.debug("Crawl task finished!") - - async def archive(self, incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> bool: - """ - Relay an embed representation of `incident` to the #incidents-archive channel. - - The following pieces of information are relayed: - * Incident message content (as embed description) - * Incident attachment (if image, shown in archive embed) - * Incident author name (as webhook author) - * Incident author avatar (as webhook avatar) - * Resolution signal `outcome` (as embed colour & footer) - * Moderator `actioned_by` (name & discriminator shown in footer) - - If `incident` contains an attachment, we try to add it to the archive embed. There is - no handing of extensions / file types - we simply dispatch the attachment file with the - webhook, and try to display it in the embed. Testing indicates that if the attachment - cannot be displayed (e.g. a text file), it's invisible in the embed, with no error. - - Return True if the relay finishes successfully. If anything goes wrong, meaning - not all information was relayed, return False. This signals that the original - message is not safe to be deleted, as we will lose some information. - """ - log.debug(f"Archiving incident: {incident.id} (outcome: {outcome}, actioned by: {actioned_by})") - embed, attachment_file = await make_embed(incident, outcome, actioned_by) - - try: - webhook = await self.bot.fetch_webhook(Webhooks.incidents_archive) - await webhook.send( - embed=embed, - username=sub_clyde(incident.author.name), - avatar_url=incident.author.avatar_url, - file=attachment_file, - ) - except Exception: - log.exception(f"Failed to archive incident {incident.id} to #incidents-archive") - return False - else: - log.trace("Message archived successfully!") - return True - - def make_confirmation_task(self, incident: discord.Message, timeout: int = 5) -> asyncio.Task: - """ - Create a task to wait `timeout` seconds for `incident` to be deleted. - - If `timeout` passes, this will raise `asyncio.TimeoutError`, signaling that we haven't - been able to confirm that the message was deleted. - """ - log.trace(f"Confirmation task will wait {timeout=} seconds for {incident.id=} to be deleted") - - def check(payload: discord.RawReactionActionEvent) -> bool: - return payload.message_id == incident.id - - coroutine = self.bot.wait_for(event="raw_message_delete", check=check, timeout=timeout) - return self.bot.loop.create_task(coroutine) - - async def process_event(self, reaction: str, incident: discord.Message, member: discord.Member) -> None: - """ - Process a `reaction_add` event in #incidents. - - First, we check that the reaction is a recognized `Signal` member, and that it was sent by - a permitted user (at least one role in `ALLOWED_ROLES`). If not, the reaction is removed. - - If the reaction was either `Signal.ACTIONED` or `Signal.NOT_ACTIONED`, we attempt to relay - the report to #incidents-archive. If successful, the original message is deleted. - - We do not release `event_lock` until we receive the corresponding `message_delete` event. - This ensures that if there is a racing event awaiting the lock, it will fail to find the - message, and will abort. There is a `timeout` to ensure that this doesn't hold the lock - forever should something go wrong. - """ - members_roles: t.Set[int] = {role.id for role in member.roles} - if not members_roles & ALLOWED_ROLES: # Intersection is truthy on at least 1 common element - log.debug(f"Removing invalid reaction: user {member} is not permitted to send signals") - await incident.remove_reaction(reaction, member) - return - - try: - signal = Signal(reaction) - except ValueError: - log.debug(f"Removing invalid reaction: emoji {reaction} is not a valid signal") - await incident.remove_reaction(reaction, member) - return - - log.trace(f"Received signal: {signal}") - - if signal not in (Signal.ACTIONED, Signal.NOT_ACTIONED): - log.debug("Reaction was valid, but no action is currently defined for it") - return - - relay_successful = await self.archive(incident, signal, actioned_by=member) - if not relay_successful: - log.trace("Original message will not be deleted as we failed to relay it to the archive") - return - - timeout = 5 # Seconds - confirmation_task = self.make_confirmation_task(incident, timeout) - - log.trace("Deleting original message") - await incident.delete() - - log.trace(f"Awaiting deletion confirmation: {timeout=} seconds") - try: - await confirmation_task - except asyncio.TimeoutError: - log.warning(f"Did not receive incident deletion confirmation within {timeout} seconds!") - else: - log.trace("Deletion was confirmed") - - async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]: - """ - Get `discord.Message` for `message_id` from cache, or API. - - We first look into the local cache to see if the message is present. - - If not, we try to fetch the message from the API. This is necessary for messages - which were sent before the bot's current session. - - In an edge-case, it is also possible that the message was already deleted, and - the API will respond with a 404. In such a case, None will be returned. - This signals that the event for `message_id` should be ignored. - """ - await self.bot.wait_until_guild_available() # First make sure that the cache is ready - log.trace(f"Resolving message for: {message_id=}") - message: t.Optional[discord.Message] = self.bot._connection._get_message(message_id) - - if message is not None: - log.trace("Message was found in cache") - return message - - log.trace("Message not found, attempting to fetch") - try: - message = await self.bot.get_channel(Channels.incidents).fetch_message(message_id) - except discord.NotFound: - log.trace("Message doesn't exist, it was likely already relayed") - except Exception: - log.exception(f"Failed to fetch message {message_id}!") - else: - log.trace("Message fetched successfully!") - return message - - @Cog.listener() - async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent) -> None: - """ - Pre-process `payload` and pass it to `process_event` if appropriate. - - We abort instantly if `payload` doesn't relate to a message sent in #incidents, - or if it was sent by a bot. - - If `payload` relates to a message in #incidents, we first ensure that `crawl_task` has - finished, to make sure we don't mutate channel state as we're crawling it. - - Next, we acquire `event_lock` - to prevent racing, events are processed one at a time. - - Once we have the lock, the `discord.Message` object for this event must be resolved. - If the lock was previously held by an event which successfully relayed the incident, - this will fail and we abort the current event. - - Finally, with both the lock and the `discord.Message` instance in our hands, we delegate - to `process_event` to handle the event. - - The justification for using a raw listener is the need to receive events for messages - which were not cached in the current session. As a result, a certain amount of - complexity is introduced, but at the moment this doesn't appear to be avoidable. - """ - if payload.channel_id != Channels.incidents or payload.member.bot: - return - - log.trace(f"Received reaction add event in #incidents, waiting for crawler: {self.crawl_task.done()=}") - await self.crawl_task - - log.trace(f"Acquiring event lock: {self.event_lock.locked()=}") - async with self.event_lock: - message = await self.resolve_message(payload.message_id) - - if message is None: - log.debug("Listener will abort as related message does not exist!") - return - - if not is_incident(message): - log.debug("Ignoring event for a non-incident message") - return - - await self.process_event(str(payload.emoji), message, payload.member) - log.trace("Releasing event lock") - - @Cog.listener() - async def on_message(self, message: discord.Message) -> None: - """Pass `message` to `add_signals` if and only if it satisfies `is_incident`.""" - if is_incident(message): - await add_signals(message) - - -def setup(bot: Bot) -> None: - """Load the Incidents cog.""" - bot.add_cog(Incidents(bot)) diff --git a/bot/cogs/moderation/infraction/__init__.py b/bot/cogs/moderation/infraction/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/bot/cogs/moderation/infraction/_scheduler.py b/bot/cogs/moderation/infraction/_scheduler.py deleted file mode 100644 index 33944a8db..000000000 --- a/bot/cogs/moderation/infraction/_scheduler.py +++ /dev/null @@ -1,463 +0,0 @@ -import logging -import textwrap -import typing as t -from abc import abstractmethod -from datetime import datetime -from gettext import ngettext - -import dateutil.parser -import discord -from discord.ext.commands import Context - -from bot import constants -from bot.api import ResponseCodeError -from bot.bot import Bot -from bot.cogs.moderation.modlog import ModLog -from bot.constants import Colours, STAFF_CHANNELS -from bot.utils import time -from bot.utils.scheduling import Scheduler -from . import _utils -from ._utils import UserSnowflake - -log = logging.getLogger(__name__) - - -class InfractionScheduler: - """Handles the application, pardoning, and expiration of infractions.""" - - def __init__(self, bot: Bot, supported_infractions: t.Container[str]): - self.bot = bot - self.scheduler = Scheduler(self.__class__.__name__) - - self.bot.loop.create_task(self.reschedule_infractions(supported_infractions)) - - def cog_unload(self) -> None: - """Cancel scheduled tasks.""" - self.scheduler.cancel_all() - - @property - def mod_log(self) -> ModLog: - """Get the currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - async def reschedule_infractions(self, supported_infractions: t.Container[str]) -> None: - """Schedule expiration for previous infractions.""" - await self.bot.wait_until_guild_available() - - log.trace(f"Rescheduling infractions for {self.__class__.__name__}.") - - infractions = await self.bot.api_client.get( - 'bot/infractions', - params={'active': 'true'} - ) - for infraction in infractions: - if infraction["expires_at"] is not None and infraction["type"] in supported_infractions: - self.schedule_expiration(infraction) - - async def reapply_infraction( - self, - infraction: _utils.Infraction, - apply_coro: t.Optional[t.Awaitable] - ) -> None: - """Reapply an infraction if it's still active or deactivate it if less than 60 sec left.""" - # Calculate the time remaining, in seconds, for the mute. - expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) - delta = (expiry - datetime.utcnow()).total_seconds() - - # Mark as inactive if less than a minute remains. - if delta < 60: - log.info( - "Infraction will be deactivated instead of re-applied " - "because less than 1 minute remains." - ) - await self.deactivate_infraction(infraction) - return - - # Allowing mod log since this is a passive action that should be logged. - await apply_coro - log.info(f"Re-applied {infraction['type']} to user {infraction['user']} upon rejoining.") - - async def apply_infraction( - self, - ctx: Context, - infraction: _utils.Infraction, - user: UserSnowflake, - action_coro: t.Optional[t.Awaitable] = None - ) -> None: - """Apply an infraction to the user, log the infraction, and optionally notify the user.""" - infr_type = infraction["type"] - icon = _utils.INFRACTION_ICONS[infr_type][0] - reason = infraction["reason"] - expiry = time.format_infraction_with_duration(infraction["expires_at"]) - id_ = infraction['id'] - - log.trace(f"Applying {infr_type} infraction #{id_} to {user}.") - - # Default values for the confirmation message and mod log. - confirm_msg = ":ok_hand: applied" - - # Specifying an expiry for a note or warning makes no sense. - if infr_type in ("note", "warning"): - expiry_msg = "" - else: - expiry_msg = f" until {expiry}" if expiry else " permanently" - - dm_result = "" - dm_log_text = "" - expiry_log_text = f"\nExpires: {expiry}" if expiry else "" - log_title = "applied" - log_content = None - failed = False - - # DM the user about the infraction if it's not a shadow/hidden infraction. - # This needs to happen before we apply the infraction, as the bot cannot - # send DMs to user that it doesn't share a guild with. If we were to - # apply kick/ban infractions first, this would mean that we'd make it - # impossible for us to deliver a DM. See python-discord/bot#982. - if not infraction["hidden"]: - dm_result = f"{constants.Emojis.failmail} " - dm_log_text = "\nDM: **Failed**" - - # Sometimes user is a discord.Object; make it a proper user. - try: - if not isinstance(user, (discord.Member, discord.User)): - user = await self.bot.fetch_user(user.id) - except discord.HTTPException as e: - log.error(f"Failed to DM {user.id}: could not fetch user (status {e.status})") - else: - # Accordingly display whether the user was successfully notified via DM. - if await _utils.notify_infraction(user, infr_type, expiry, reason, icon): - dm_result = ":incoming_envelope: " - dm_log_text = "\nDM: Sent" - - end_msg = "" - if infraction["actor"] == self.bot.user.id: - log.trace( - f"Infraction #{id_} actor is bot; including the reason in the confirmation message." - ) - if reason: - end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})" - elif ctx.channel.id not in STAFF_CHANNELS: - log.trace( - f"Infraction #{id_} context is not in a staff channel; omitting infraction count." - ) - else: - log.trace(f"Fetching total infraction count for {user}.") - - infractions = await self.bot.api_client.get( - "bot/infractions", - params={"user__id": str(user.id)} - ) - total = len(infractions) - end_msg = f" ({total} infraction{ngettext('', 's', total)} total)" - - # Execute the necessary actions to apply the infraction on Discord. - if action_coro: - log.trace(f"Awaiting the infraction #{id_} application action coroutine.") - try: - await action_coro - if expiry: - # Schedule the expiration of the infraction. - self.schedule_expiration(infraction) - except discord.HTTPException as e: - # Accordingly display that applying the infraction failed. - confirm_msg = ":x: failed to apply" - expiry_msg = "" - log_content = ctx.author.mention - log_title = "failed to apply" - - log_msg = f"Failed to apply {infr_type} infraction #{id_} to {user}" - if isinstance(e, discord.Forbidden): - log.warning(f"{log_msg}: bot lacks permissions.") - else: - log.exception(log_msg) - failed = True - - if failed: - log.trace(f"Deleted infraction {infraction['id']} from database because applying infraction failed.") - try: - await self.bot.api_client.delete(f"bot/infractions/{id_}") - except ResponseCodeError as e: - confirm_msg += " and failed to delete" - log_title += " and failed to delete" - log.error(f"Deletion of {infr_type} infraction #{id_} failed with error code {e.status}.") - infr_message = "" - else: - infr_message = f" **{infr_type}** to {user.mention}{expiry_msg}{end_msg}" - - # Send a confirmation message to the invoking context. - log.trace(f"Sending infraction #{id_} confirmation message.") - await ctx.send(f"{dm_result}{confirm_msg}{infr_message}.") - - # Send a log message to the mod log. - log.trace(f"Sending apply mod log for infraction #{id_}.") - await self.mod_log.send_log_message( - icon_url=icon, - colour=Colours.soft_red, - title=f"Infraction {log_title}: {infr_type}", - thumbnail=user.avatar_url_as(static_format="png"), - text=textwrap.dedent(f""" - Member: {user.mention} (`{user.id}`) - Actor: {ctx.message.author}{dm_log_text}{expiry_log_text} - Reason: {reason} - """), - content=log_content, - footer=f"ID {infraction['id']}" - ) - - log.info(f"Applied {infr_type} infraction #{id_} to {user}.") - - async def pardon_infraction( - self, - ctx: Context, - infr_type: str, - user: UserSnowflake, - send_msg: bool = True - ) -> None: - """ - Prematurely end an infraction for a user and log the action in the mod log. - - If `send_msg` is True, then a pardoning confirmation message will be sent to - the context channel. Otherwise, no such message will be sent. - """ - log.trace(f"Pardoning {infr_type} infraction for {user}.") - - # Check the current active infraction - log.trace(f"Fetching active {infr_type} infractions for {user}.") - response = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': infr_type, - 'user__id': user.id - } - ) - - if not response: - log.debug(f"No active {infr_type} infraction found for {user}.") - await ctx.send(f":x: There's no active {infr_type} infraction for user {user.mention}.") - return - - # Deactivate the infraction and cancel its scheduled expiration task. - log_text = await self.deactivate_infraction(response[0], send_log=False) - - log_text["Member"] = f"{user.mention}(`{user.id}`)" - log_text["Actor"] = str(ctx.message.author) - log_content = None - id_ = response[0]['id'] - footer = f"ID: {id_}" - - # If multiple active infractions were found, mark them as inactive in the database - # and cancel their expiration tasks. - if len(response) > 1: - log.info( - f"Found more than one active {infr_type} infraction for user {user.id}; " - "deactivating the extra active infractions too." - ) - - footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}" - - log_note = f"Found multiple **active** {infr_type} infractions in the database." - if "Note" in log_text: - log_text["Note"] = f" {log_note}" - else: - log_text["Note"] = log_note - - # deactivate_infraction() is not called again because: - # 1. Discord cannot store multiple active bans or assign multiples of the same role - # 2. It would send a pardon DM for each active infraction, which is redundant - for infraction in response[1:]: - id_ = infraction['id'] - try: - # Mark infraction as inactive in the database. - await self.bot.api_client.patch( - f"bot/infractions/{id_}", - json={"active": False} - ) - except ResponseCodeError: - log.exception(f"Failed to deactivate infraction #{id_} ({infr_type})") - # This is simpler and cleaner than trying to concatenate all the errors. - log_text["Failure"] = "See bot's logs for details." - - # Cancel pending expiration task. - if infraction["expires_at"] is not None: - self.scheduler.cancel(infraction["id"]) - - # Accordingly display whether the user was successfully notified via DM. - dm_emoji = "" - if log_text.get("DM") == "Sent": - dm_emoji = ":incoming_envelope: " - elif "DM" in log_text: - dm_emoji = f"{constants.Emojis.failmail} " - - # Accordingly display whether the pardon failed. - if "Failure" in log_text: - confirm_msg = ":x: failed to pardon" - log_title = "pardon failed" - log_content = ctx.author.mention - - log.warning(f"Failed to pardon {infr_type} infraction #{id_} for {user}.") - else: - confirm_msg = ":ok_hand: pardoned" - log_title = "pardoned" - - log.info(f"Pardoned {infr_type} infraction #{id_} for {user}.") - - # Send a confirmation message to the invoking context. - if send_msg: - log.trace(f"Sending infraction #{id_} pardon confirmation message.") - await ctx.send( - f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. " - f"{log_text.get('Failure', '')}" - ) - - # Move reason to end of entry to avoid cutting out some keys - log_text["Reason"] = log_text.pop("Reason") - - # Send a log message to the mod log. - await self.mod_log.send_log_message( - icon_url=_utils.INFRACTION_ICONS[infr_type][1], - colour=Colours.soft_green, - title=f"Infraction {log_title}: {infr_type}", - thumbnail=user.avatar_url_as(static_format="png"), - text="\n".join(f"{k}: {v}" for k, v in log_text.items()), - footer=footer, - content=log_content, - ) - - async def deactivate_infraction( - self, - infraction: _utils.Infraction, - send_log: bool = True - ) -> t.Dict[str, str]: - """ - Deactivate an active infraction and return a dictionary of lines to send in a mod log. - - The infraction is removed from Discord, marked as inactive in the database, and has its - expiration task cancelled. If `send_log` is True, a mod log is sent for the - deactivation of the infraction. - - Infractions of unsupported types will raise a ValueError. - """ - guild = self.bot.get_guild(constants.Guild.id) - mod_role = guild.get_role(constants.Roles.moderators) - user_id = infraction["user"] - actor = infraction["actor"] - type_ = infraction["type"] - id_ = infraction["id"] - inserted_at = infraction["inserted_at"] - expiry = infraction["expires_at"] - - log.info(f"Marking infraction #{id_} as inactive (expired).") - - expiry = dateutil.parser.isoparse(expiry).replace(tzinfo=None) if expiry else None - created = time.format_infraction_with_duration(inserted_at, expiry) - - log_content = None - log_text = { - "Member": f"<@{user_id}>", - "Actor": str(self.bot.get_user(actor) or actor), - "Reason": infraction["reason"], - "Created": created, - } - - try: - log.trace("Awaiting the pardon action coroutine.") - returned_log = await self._pardon_action(infraction) - - if returned_log is not None: - log_text = {**log_text, **returned_log} # Merge the logs together - else: - raise ValueError( - f"Attempted to deactivate an unsupported infraction #{id_} ({type_})!" - ) - except discord.Forbidden: - log.warning(f"Failed to deactivate infraction #{id_} ({type_}): bot lacks permissions.") - log_text["Failure"] = "The bot lacks permissions to do this (role hierarchy?)" - log_content = mod_role.mention - except discord.HTTPException as e: - log.exception(f"Failed to deactivate infraction #{id_} ({type_})") - log_text["Failure"] = f"HTTPException with status {e.status} and code {e.code}." - log_content = mod_role.mention - - # Check if the user is currently being watched by Big Brother. - try: - log.trace(f"Determining if user {user_id} is currently being watched by Big Brother.") - - active_watch = await self.bot.api_client.get( - "bot/infractions", - params={ - "active": "true", - "type": "watch", - "user__id": user_id - } - ) - - log_text["Watching"] = "Yes" if active_watch else "No" - except ResponseCodeError: - log.exception(f"Failed to fetch watch status for user {user_id}") - log_text["Watching"] = "Unknown - failed to fetch watch status." - - try: - # Mark infraction as inactive in the database. - log.trace(f"Marking infraction #{id_} as inactive in the database.") - await self.bot.api_client.patch( - f"bot/infractions/{id_}", - json={"active": False} - ) - except ResponseCodeError as e: - log.exception(f"Failed to deactivate infraction #{id_} ({type_})") - log_line = f"API request failed with code {e.status}." - log_content = mod_role.mention - - # Append to an existing failure message if possible - if "Failure" in log_text: - log_text["Failure"] += f" {log_line}" - else: - log_text["Failure"] = log_line - - # Cancel the expiration task. - if infraction["expires_at"] is not None: - self.scheduler.cancel(infraction["id"]) - - # Send a log message to the mod log. - if send_log: - log_title = "expiration failed" if "Failure" in log_text else "expired" - - user = self.bot.get_user(user_id) - avatar = user.avatar_url_as(static_format="png") if user else None - - # Move reason to end so when reason is too long, this is not gonna cut out required items. - log_text["Reason"] = log_text.pop("Reason") - - log.trace(f"Sending deactivation mod log for infraction #{id_}.") - await self.mod_log.send_log_message( - icon_url=_utils.INFRACTION_ICONS[type_][1], - colour=Colours.soft_green, - title=f"Infraction {log_title}: {type_}", - thumbnail=avatar, - text="\n".join(f"{k}: {v}" for k, v in log_text.items()), - footer=f"ID: {id_}", - content=log_content, - ) - - return log_text - - @abstractmethod - async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]: - """ - Execute deactivation steps specific to the infraction's type and return a log dict. - - If an infraction type is unsupported, return None instead. - """ - raise NotImplementedError - - def schedule_expiration(self, infraction: _utils.Infraction) -> None: - """ - Marks an infraction expired after the delay from time of scheduling to time of expiration. - - At the time of expiration, the infraction is marked as inactive on the website and the - expiration task is cancelled. - """ - expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) - self.scheduler.schedule_at(expiry, infraction["id"], self.deactivate_infraction(infraction)) diff --git a/bot/cogs/moderation/infraction/_utils.py b/bot/cogs/moderation/infraction/_utils.py deleted file mode 100644 index fb55287b6..000000000 --- a/bot/cogs/moderation/infraction/_utils.py +++ /dev/null @@ -1,201 +0,0 @@ -import logging -import textwrap -import typing as t -from datetime import datetime - -import discord -from discord.ext.commands import Context - -from bot.api import ResponseCodeError -from bot.constants import Colours, Icons - -log = logging.getLogger(__name__) - -# apply icon, pardon icon -INFRACTION_ICONS = { - "ban": (Icons.user_ban, Icons.user_unban), - "kick": (Icons.sign_out, None), - "mute": (Icons.user_mute, Icons.user_unmute), - "note": (Icons.user_warn, None), - "superstar": (Icons.superstarify, Icons.unsuperstarify), - "warning": (Icons.user_warn, None), -} -RULES_URL = "https://pythondiscord.com/pages/rules" -APPEALABLE_INFRACTIONS = ("ban", "mute") - -# Type aliases -UserObject = t.Union[discord.Member, discord.User] -UserSnowflake = t.Union[UserObject, discord.Object] -Infraction = t.Dict[str, t.Union[str, int, bool]] - - -async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]: - """ - Create a new user in the database. - - Used when an infraction needs to be applied on a user absent in the guild. - """ - log.trace(f"Attempting to add user {user.id} to the database.") - - if not isinstance(user, (discord.Member, discord.User)): - log.debug("The user being added to the DB is not a Member or User object.") - - payload = { - 'discriminator': int(getattr(user, 'discriminator', 0)), - 'id': user.id, - 'in_guild': False, - 'name': getattr(user, 'name', 'Name unknown'), - 'roles': [] - } - - try: - response = await ctx.bot.api_client.post('bot/users', json=payload) - log.info(f"User {user.id} added to the DB.") - return response - except ResponseCodeError as e: - log.error(f"Failed to add user {user.id} to the DB. {e}") - await ctx.send(f":x: The attempt to add the user to the DB failed: status {e.status}") - - -async def post_infraction( - ctx: Context, - user: UserSnowflake, - infr_type: str, - reason: str, - expires_at: datetime = None, - hidden: bool = False, - active: bool = True -) -> t.Optional[dict]: - """Posts an infraction to the API.""" - log.trace(f"Posting {infr_type} infraction for {user} to the API.") - - payload = { - "actor": ctx.message.author.id, - "hidden": hidden, - "reason": reason, - "type": infr_type, - "user": user.id, - "active": active - } - if expires_at: - payload['expires_at'] = expires_at.isoformat() - - # Try to apply the infraction. If it fails because the user doesn't exist, try to add it. - for should_post_user in (True, False): - try: - response = await ctx.bot.api_client.post('bot/infractions', json=payload) - return response - except ResponseCodeError as e: - if e.status == 400 and 'user' in e.response_json: - # Only one attempt to add the user to the database, not two: - if not should_post_user or await post_user(ctx, user) is None: - return - else: - log.exception(f"Unexpected error while adding an infraction for {user}:") - await ctx.send(f":x: There was an error adding the infraction: status {e.status}.") - return - - -async def get_active_infraction( - ctx: Context, - user: UserSnowflake, - infr_type: str, - send_msg: bool = True -) -> t.Optional[dict]: - """ - Retrieves an active infraction of the given type for the user. - - If `send_msg` is True and the user has an active infraction matching the `infr_type` parameter, - then a message for the moderator will be sent to the context channel letting them know. - Otherwise, no message will be sent. - """ - log.trace(f"Checking if {user} has active infractions of type {infr_type}.") - - active_infractions = await ctx.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': infr_type, - 'user__id': str(user.id) - } - ) - if active_infractions: - # Checks to see if the moderator should be told there is an active infraction - if send_msg: - log.trace(f"{user} has active infractions of type {infr_type}.") - await ctx.send( - f":x: According to my records, this user already has a {infr_type} infraction. " - f"See infraction **#{active_infractions[0]['id']}**." - ) - return active_infractions[0] - else: - log.trace(f"{user} does not have active infractions of type {infr_type}.") - - -async def notify_infraction( - user: UserObject, - infr_type: str, - expires_at: t.Optional[str] = None, - reason: t.Optional[str] = None, - icon_url: str = Icons.token_removed -) -> bool: - """DM a user about their new infraction and return True if the DM is successful.""" - log.trace(f"Sending {user} a DM about their {infr_type} infraction.") - - text = textwrap.dedent(f""" - **Type:** {infr_type.capitalize()} - **Expires:** {expires_at or "N/A"} - **Reason:** {reason or "No reason provided."} - """) - - embed = discord.Embed( - description=textwrap.shorten(text, width=2048, placeholder="..."), - colour=Colours.soft_red - ) - - embed.set_author(name="Infraction information", icon_url=icon_url, url=RULES_URL) - embed.title = f"Please review our rules over at {RULES_URL}" - embed.url = RULES_URL - - if infr_type in APPEALABLE_INFRACTIONS: - embed.set_footer( - text="To appeal this infraction, send an e-mail to appeals@pythondiscord.com" - ) - - return await send_private_embed(user, embed) - - -async def notify_pardon( - user: UserObject, - title: str, - content: str, - icon_url: str = Icons.user_verified -) -> bool: - """DM a user about their pardoned infraction and return True if the DM is successful.""" - log.trace(f"Sending {user} a DM about their pardoned infraction.") - - embed = discord.Embed( - description=content, - colour=Colours.soft_green - ) - - embed.set_author(name=title, icon_url=icon_url) - - return await send_private_embed(user, embed) - - -async def send_private_embed(user: UserObject, embed: discord.Embed) -> bool: - """ - A helper method for sending an embed to a user's DMs. - - Returns a boolean indicator of DM success. - """ - try: - await user.send(embed=embed) - return True - except (discord.HTTPException, discord.Forbidden, discord.NotFound): - log.debug( - f"Infraction-related information could not be sent to user {user} ({user.id}). " - "The user either could not be retrieved or probably disabled their DMs." - ) - return False diff --git a/bot/cogs/moderation/infraction/infractions.py b/bot/cogs/moderation/infraction/infractions.py deleted file mode 100644 index cb459b447..000000000 --- a/bot/cogs/moderation/infraction/infractions.py +++ /dev/null @@ -1,375 +0,0 @@ -import logging -import textwrap -import typing as t - -import discord -from discord import Member -from discord.ext import commands -from discord.ext.commands import Context, command - -from bot import constants -from bot.bot import Bot -from bot.constants import Event -from bot.converters import Expiry, FetchedMember -from bot.decorators import respect_role_hierarchy -from bot.utils.checks import with_role_check -from . import _utils -from ._scheduler import InfractionScheduler -from ._utils import UserSnowflake - -log = logging.getLogger(__name__) - - -class Infractions(InfractionScheduler, commands.Cog): - """Apply and pardon infractions on users for moderation purposes.""" - - category = "Moderation" - category_description = "Server moderation tools." - - def __init__(self, bot: Bot): - super().__init__(bot, supported_infractions={"ban", "kick", "mute", "note", "warning"}) - - self.category = "Moderation" - self._muted_role = discord.Object(constants.Roles.muted) - - @commands.Cog.listener() - async def on_member_join(self, member: Member) -> None: - """Reapply active mute infractions for returning members.""" - active_mutes = await self.bot.api_client.get( - "bot/infractions", - params={ - "active": "true", - "type": "mute", - "user__id": member.id - } - ) - - if active_mutes: - reason = f"Re-applying active mute: {active_mutes[0]['id']}" - action = member.add_roles(self._muted_role, reason=reason) - - await self.reapply_infraction(active_mutes[0], action) - - # region: Permanent infractions - - @command() - async def warn(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: - """Warn a user for the given reason.""" - infraction = await _utils.post_infraction(ctx, user, "warning", reason, active=False) - if infraction is None: - return - - await self.apply_infraction(ctx, infraction, user) - - @command() - async def kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: - """Kick a user for the given reason.""" - await self.apply_kick(ctx, user, reason) - - @command() - async def ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: - """Permanently ban a user for the given reason and stop watching them with Big Brother.""" - await self.apply_ban(ctx, user, reason) - - # endregion - # region: Temporary infractions - - @command(aliases=["mute"]) - async def tempmute(self, ctx: Context, user: Member, duration: Expiry, *, reason: t.Optional[str] = None) -> None: - """ - Temporarily mute a user for the given reason and duration. - - 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. - """ - await self.apply_mute(ctx, user, reason, expires_at=duration) - - @command() - async def tempban( - self, - ctx: Context, - user: FetchedMember, - duration: Expiry, - *, - reason: t.Optional[str] = None - ) -> None: - """ - Temporarily ban a user for the given reason and duration. - - 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. - """ - await self.apply_ban(ctx, user, reason, expires_at=duration) - - # endregion - # region: Permanent shadow infractions - - @command(hidden=True) - async def note(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: - """Create a private note for a user with the given reason without notifying the user.""" - infraction = await _utils.post_infraction(ctx, user, "note", reason, hidden=True, active=False) - if infraction is None: - return - - await self.apply_infraction(ctx, infraction, user) - - @command(hidden=True, aliases=['shadowkick', 'skick']) - async def shadow_kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: - """Kick a user for the given reason without notifying the user.""" - await self.apply_kick(ctx, user, reason, hidden=True) - - @command(hidden=True, aliases=['shadowban', 'sban']) - async def shadow_ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: - """Permanently ban a user for the given reason without notifying the user.""" - await self.apply_ban(ctx, user, reason, hidden=True) - - # endregion - # region: Temporary shadow infractions - - @command(hidden=True, aliases=["shadowtempmute, stempmute", "shadowmute", "smute"]) - async def shadow_tempmute( - self, ctx: Context, - user: Member, - duration: Expiry, - *, - reason: t.Optional[str] = None - ) -> None: - """ - Temporarily mute a user for the given reason and duration without notifying the user. - - 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. - """ - await self.apply_mute(ctx, user, reason, expires_at=duration, hidden=True) - - @command(hidden=True, aliases=["shadowtempban, stempban"]) - async def shadow_tempban( - self, - ctx: Context, - user: FetchedMember, - duration: Expiry, - *, - reason: t.Optional[str] = None - ) -> None: - """ - Temporarily ban a user for the given reason and duration without notifying the user. - - 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. - """ - await self.apply_ban(ctx, user, reason, expires_at=duration, hidden=True) - - # endregion - # region: Remove infractions (un- commands) - - @command() - async def unmute(self, ctx: Context, user: FetchedMember) -> None: - """Prematurely end the active mute infraction for the user.""" - await self.pardon_infraction(ctx, "mute", user) - - @command() - async def unban(self, ctx: Context, user: FetchedMember) -> None: - """Prematurely end the active ban infraction for the user.""" - await self.pardon_infraction(ctx, "ban", user) - - # endregion - # region: Base apply functions - - async def apply_mute(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None: - """Apply a mute infraction with kwargs passed to `post_infraction`.""" - if await _utils.get_active_infraction(ctx, user, "mute"): - return - - infraction = await _utils.post_infraction(ctx, user, "mute", reason, active=True, **kwargs) - if infraction is None: - return - - self.mod_log.ignore(Event.member_update, user.id) - - async def action() -> None: - await user.add_roles(self._muted_role, reason=reason) - - log.trace(f"Attempting to kick {user} from voice because they've been muted.") - await user.move_to(None, reason=reason) - - await self.apply_infraction(ctx, infraction, user, action()) - - @respect_role_hierarchy() - async def apply_kick(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None: - """Apply a kick infraction with kwargs passed to `post_infraction`.""" - infraction = await _utils.post_infraction(ctx, user, "kick", reason, active=False, **kwargs) - if infraction is None: - return - - self.mod_log.ignore(Event.member_remove, user.id) - - if reason: - reason = textwrap.shorten(reason, width=512, placeholder="...") - - action = user.kick(reason=reason) - await self.apply_infraction(ctx, infraction, user, action) - - @respect_role_hierarchy() - async def apply_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None: - """ - Apply a ban infraction with kwargs passed to `post_infraction`. - - Will also remove the banned user from the Big Brother watch list if applicable. - """ - # In the case of a permanent ban, we don't need get_active_infractions to tell us if one is active - is_temporary = kwargs.get("expires_at") is not None - active_infraction = await _utils.get_active_infraction(ctx, user, "ban", is_temporary) - - if active_infraction: - if is_temporary: - log.trace("Tempban ignored as it cannot overwrite an active ban.") - return - - if active_infraction.get('expires_at') is None: - log.trace("Permaban already exists, notify.") - await ctx.send(f":x: User is already permanently banned (#{active_infraction['id']}).") - return - - log.trace("Old tempban is being replaced by new permaban.") - await self.pardon_infraction(ctx, "ban", user, is_temporary) - - infraction = await _utils.post_infraction(ctx, user, "ban", reason, active=True, **kwargs) - if infraction is None: - return - - self.mod_log.ignore(Event.member_remove, user.id) - - if reason: - reason = textwrap.shorten(reason, width=512, placeholder="...") - - action = ctx.guild.ban(user, reason=reason, delete_message_days=0) - await self.apply_infraction(ctx, infraction, user, action) - - if infraction.get('expires_at') is not None: - log.trace(f"Ban isn't permanent; user {user} won't be unwatched by Big Brother.") - return - - bb_cog = self.bot.get_cog("Big Brother") - if not bb_cog: - log.error(f"Big Brother cog not loaded; perma-banned user {user} won't be unwatched.") - return - - log.trace(f"Big Brother cog loaded; attempting to unwatch perma-banned user {user}.") - - bb_reason = "User has been permanently banned from the server. Automatically removed." - await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False) - - # endregion - # region: Base pardon functions - - async def pardon_mute(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]: - """Remove a user's muted role, DM them a notification, and return a log dict.""" - user = guild.get_member(user_id) - log_text = {} - - if user: - # Remove the muted role. - self.mod_log.ignore(Event.member_update, user.id) - await user.remove_roles(self._muted_role, reason=reason) - - # DM the user about the expiration. - notified = await _utils.notify_pardon( - user=user, - title="You have been unmuted", - content="You may now send messages in the server.", - icon_url=_utils.INFRACTION_ICONS["mute"][1] - ) - - log_text["Member"] = f"{user.mention}(`{user.id}`)" - log_text["DM"] = "Sent" if notified else "**Failed**" - else: - log.info(f"Failed to unmute user {user_id}: user not found") - log_text["Failure"] = "User was not found in the guild." - - return log_text - - async def pardon_ban(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]: - """Remove a user's ban on the Discord guild and return a log dict.""" - user = discord.Object(user_id) - log_text = {} - - self.mod_log.ignore(Event.member_unban, user_id) - - try: - await guild.unban(user, reason=reason) - except discord.NotFound: - log.info(f"Failed to unban user {user_id}: no active ban found on Discord") - log_text["Note"] = "No active ban found on Discord." - - return log_text - - async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]: - """ - Execute deactivation steps specific to the infraction's type and return a log dict. - - If an infraction type is unsupported, return None instead. - """ - guild = self.bot.get_guild(constants.Guild.id) - user_id = infraction["user"] - reason = f"Infraction #{infraction['id']} expired or was pardoned." - - if infraction["type"] == "mute": - return await self.pardon_mute(user_id, guild, reason) - elif infraction["type"] == "ban": - return await self.pardon_ban(user_id, guild, reason) - - # endregion - - # This cannot be static (must have a __func__ attribute). - def cog_check(self, ctx: Context) -> bool: - """Only allow moderators to invoke the commands in this cog.""" - return with_role_check(ctx, *constants.MODERATION_ROLES) - - # This cannot be static (must have a __func__ attribute). - async def cog_command_error(self, ctx: Context, error: Exception) -> None: - """Send a notification to the invoking context on a Union failure.""" - if isinstance(error, commands.BadUnionArgument): - if discord.User in error.converters or discord.Member in error.converters: - await ctx.send(str(error.errors[0])) - error.handled = True - - -def setup(bot: Bot) -> None: - """Load the Infractions cog.""" - bot.add_cog(Infractions(bot)) diff --git a/bot/cogs/moderation/infraction/management.py b/bot/cogs/moderation/infraction/management.py deleted file mode 100644 index 9e7ae8113..000000000 --- a/bot/cogs/moderation/infraction/management.py +++ /dev/null @@ -1,310 +0,0 @@ -import logging -import textwrap -import typing as t -from datetime import datetime - -import discord -from discord.ext import commands -from discord.ext.commands import Context - -from bot import constants -from bot.bot import Bot -from bot.cogs.moderation.modlog import ModLog -from bot.converters import Expiry, InfractionSearchQuery, allowed_strings, proxy_user -from bot.pagination import LinePaginator -from bot.utils import time -from bot.utils.checks import in_whitelist_check, with_role_check -from . import _utils -from .infractions import Infractions - -log = logging.getLogger(__name__) - - -class ModManagement(commands.Cog): - """Management of infractions.""" - - category = "Moderation" - - def __init__(self, bot: Bot): - self.bot = bot - - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - @property - def infractions_cog(self) -> Infractions: - """Get currently loaded Infractions cog instance.""" - return self.bot.get_cog("Infractions") - - # region: Edit infraction commands - - @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True) - async def infraction_group(self, ctx: Context) -> None: - """Infraction manipulation commands.""" - await ctx.send_help(ctx.command) - - @infraction_group.command(name='edit') - async def infraction_edit( - self, - ctx: Context, - infraction_id: t.Union[int, allowed_strings("l", "last", "recent")], # noqa: F821 - duration: t.Union[Expiry, allowed_strings("p", "permanent"), None], # noqa: F821 - *, - reason: str = None - ) -> None: - """ - Edit the duration and/or the reason of an infraction. - - Durations are relative to the time of updating and should be appended with a unit of time. - Units (∗case-sensitive): - \u2003`y` - years - \u2003`m` - months∗ - \u2003`w` - weeks - \u2003`d` - days - \u2003`h` - hours - \u2003`M` - minutes∗ - \u2003`s` - seconds - - Use "l", "last", or "recent" as the infraction ID to specify that the most recent infraction - authored by the command invoker should be edited. - - Use "p" or "permanent" to mark the infraction as permanent. Alternatively, an ISO 8601 - timestamp can be provided for the duration. - """ - if duration is None and reason is None: - # Unlike UserInputError, the error handler will show a specified message for BadArgument - raise commands.BadArgument("Neither a new expiry nor a new reason was specified.") - - # Retrieve the previous infraction for its information. - if isinstance(infraction_id, str): - params = { - "actor__id": ctx.author.id, - "ordering": "-inserted_at" - } - infractions = await self.bot.api_client.get("bot/infractions", params=params) - - if infractions: - old_infraction = infractions[0] - infraction_id = old_infraction["id"] - else: - await ctx.send( - ":x: Couldn't find most recent infraction; you have never given an infraction." - ) - return - else: - old_infraction = await self.bot.api_client.get(f"bot/infractions/{infraction_id}") - - request_data = {} - confirm_messages = [] - log_text = "" - - if duration is not None and not old_infraction['active']: - if reason is None: - await ctx.send(":x: Cannot edit the expiration of an expired infraction.") - return - confirm_messages.append("expiry unchanged (infraction already expired)") - elif isinstance(duration, str): - request_data['expires_at'] = None - confirm_messages.append("marked as permanent") - elif duration is not None: - request_data['expires_at'] = duration.isoformat() - expiry = time.format_infraction_with_duration(request_data['expires_at']) - confirm_messages.append(f"set to expire on {expiry}") - else: - confirm_messages.append("expiry unchanged") - - if reason: - request_data['reason'] = reason - confirm_messages.append("set a new reason") - log_text += f""" - Previous reason: {old_infraction['reason']} - New reason: {reason} - """.rstrip() - else: - confirm_messages.append("reason unchanged") - - # Update the infraction - new_infraction = await self.bot.api_client.patch( - f'bot/infractions/{infraction_id}', - json=request_data, - ) - - # Re-schedule infraction if the expiration has been updated - if 'expires_at' in request_data: - # A scheduled task should only exist if the old infraction wasn't permanent - if old_infraction['expires_at']: - self.infractions_cog.scheduler.cancel(new_infraction['id']) - - # If the infraction was not marked as permanent, schedule a new expiration task - if request_data['expires_at']: - self.infractions_cog.schedule_expiration(new_infraction) - - log_text += f""" - Previous expiry: {old_infraction['expires_at'] or "Permanent"} - New expiry: {new_infraction['expires_at'] or "Permanent"} - """.rstrip() - - changes = ' & '.join(confirm_messages) - await ctx.send(f":ok_hand: Updated infraction #{infraction_id}: {changes}") - - # Get information about the infraction's user - user_id = new_infraction['user'] - user = ctx.guild.get_member(user_id) - - if user: - user_text = f"{user.mention} (`{user.id}`)" - thumbnail = user.avatar_url_as(static_format="png") - else: - user_text = f"`{user_id}`" - thumbnail = None - - # The infraction's actor - actor_id = new_infraction['actor'] - actor = ctx.guild.get_member(actor_id) or f"`{actor_id}`" - - await self.mod_log.send_log_message( - icon_url=constants.Icons.pencil, - colour=discord.Colour.blurple(), - title="Infraction edited", - thumbnail=thumbnail, - text=textwrap.dedent(f""" - Member: {user_text} - Actor: {actor} - Edited by: {ctx.message.author}{log_text} - """) - ) - - # endregion - # region: Search infractions - - @infraction_group.group(name="search", invoke_without_command=True) - async def infraction_search_group(self, ctx: Context, query: InfractionSearchQuery) -> None: - """Searches for infractions in the database.""" - if isinstance(query, discord.User): - await ctx.invoke(self.search_user, query) - else: - await ctx.invoke(self.search_reason, query) - - @infraction_search_group.command(name="user", aliases=("member", "id")) - async def search_user(self, ctx: Context, user: t.Union[discord.User, proxy_user]) -> None: - """Search for infractions by member.""" - infraction_list = await self.bot.api_client.get( - 'bot/infractions', - params={'user__id': str(user.id)} - ) - embed = discord.Embed( - title=f"Infractions for {user} ({len(infraction_list)} total)", - colour=discord.Colour.orange() - ) - await self.send_infraction_list(ctx, embed, infraction_list) - - @infraction_search_group.command(name="reason", aliases=("match", "regex", "re")) - async def search_reason(self, ctx: Context, reason: str) -> None: - """Search for infractions by their reason. Use Re2 for matching.""" - infraction_list = await self.bot.api_client.get( - 'bot/infractions', - params={'search': reason} - ) - embed = discord.Embed( - title=f"Infractions matching `{reason}` ({len(infraction_list)} total)", - colour=discord.Colour.orange() - ) - await self.send_infraction_list(ctx, embed, infraction_list) - - # endregion - # region: Utility functions - - async def send_infraction_list( - self, - ctx: Context, - embed: discord.Embed, - infractions: t.Iterable[_utils.Infraction] - ) -> None: - """Send a paginated embed of infractions for the specified user.""" - if not infractions: - await ctx.send(":warning: No infractions could be found for that query.") - return - - lines = tuple( - self.infraction_to_string(infraction) - for infraction in infractions - ) - - await LinePaginator.paginate( - lines, - ctx=ctx, - embed=embed, - empty=True, - max_lines=3, - max_size=1000 - ) - - def infraction_to_string(self, infraction: _utils.Infraction) -> str: - """Convert the infraction object to a string representation.""" - actor_id = infraction["actor"] - guild = self.bot.get_guild(constants.Guild.id) - actor = guild.get_member(actor_id) - active = infraction["active"] - user_id = infraction["user"] - hidden = infraction["hidden"] - created = time.format_infraction(infraction["inserted_at"]) - - if active: - remaining = time.until_expiration(infraction["expires_at"]) or "Expired" - else: - remaining = "Inactive" - - if infraction["expires_at"] is None: - expires = "*Permanent*" - else: - date_from = datetime.strptime(created, time.INFRACTION_FORMAT) - expires = time.format_infraction_with_duration(infraction["expires_at"], date_from) - - lines = textwrap.dedent(f""" - {"**===============**" if active else "==============="} - Status: {"__**Active**__" if active else "Inactive"} - User: {self.bot.get_user(user_id)} (`{user_id}`) - Type: **{infraction["type"]}** - Shadow: {hidden} - Created: {created} - Expires: {expires} - Remaining: {remaining} - Actor: {actor.mention if actor else actor_id} - ID: `{infraction["id"]}` - Reason: {infraction["reason"] or "*None*"} - {"**===============**" if active else "==============="} - """) - - return lines.strip() - - # endregion - - # This cannot be static (must have a __func__ attribute). - def cog_check(self, ctx: Context) -> bool: - """Only allow moderators inside moderator channels to invoke the commands in this cog.""" - checks = [ - with_role_check(ctx, *constants.MODERATION_ROLES), - in_whitelist_check( - ctx, - channels=constants.MODERATION_CHANNELS, - categories=[constants.Categories.modmail], - redirect=None, - fail_silently=True, - ) - ] - return all(checks) - - # This cannot be static (must have a __func__ attribute). - async def cog_command_error(self, ctx: Context, error: Exception) -> None: - """Send a notification to the invoking context on a Union failure.""" - if isinstance(error, commands.BadUnionArgument): - if discord.User in error.converters: - await ctx.send(str(error.errors[0])) - error.handled = True - - -def setup(bot: Bot) -> None: - """Load the ModManagement cog.""" - bot.add_cog(ModManagement(bot)) diff --git a/bot/cogs/moderation/infraction/superstarify.py b/bot/cogs/moderation/infraction/superstarify.py deleted file mode 100644 index 7dc5b4691..000000000 --- a/bot/cogs/moderation/infraction/superstarify.py +++ /dev/null @@ -1,244 +0,0 @@ -import json -import logging -import random -import textwrap -import typing as t -from pathlib import Path - -from discord import Colour, Embed, Member -from discord.ext.commands import Cog, Context, command - -from bot import constants -from bot.bot import Bot -from bot.converters import Expiry -from bot.utils.checks import with_role_check -from bot.utils.time import format_infraction -from . import _utils -from ._scheduler import InfractionScheduler - -log = logging.getLogger(__name__) -NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#nickname-policy" - -with Path("bot/resources/stars.json").open(encoding="utf-8") as stars_file: - STAR_NAMES = json.load(stars_file) - - -class Superstarify(InfractionScheduler, Cog): - """A set of commands to moderate terrible nicknames.""" - - def __init__(self, bot: Bot): - super().__init__(bot, supported_infractions={"superstar"}) - - @Cog.listener() - async def on_member_update(self, before: Member, after: Member) -> None: - """Revert nickname edits if the user has an active superstarify infraction.""" - if before.display_name == after.display_name: - return # User didn't change their nickname. Abort! - - log.trace( - f"{before} ({before.display_name}) is trying to change their nickname to " - f"{after.display_name}. Checking if the user is in superstar-prison..." - ) - - active_superstarifies = await self.bot.api_client.get( - "bot/infractions", - params={ - "active": "true", - "type": "superstar", - "user__id": str(before.id) - } - ) - - if not active_superstarifies: - log.trace(f"{before} has no active superstar infractions.") - return - - infraction = active_superstarifies[0] - forced_nick = self.get_nick(infraction["id"], before.id) - if after.display_name == forced_nick: - return # Nick change was triggered by this event. Ignore. - - log.info( - f"{after.display_name} ({after.id}) tried to escape superstar prison. " - f"Changing the nick back to {before.display_name}." - ) - await after.edit( - nick=forced_nick, - reason=f"Superstarified member tried to escape the prison: {infraction['id']}" - ) - - notified = await _utils.notify_infraction( - user=after, - infr_type="Superstarify", - expires_at=format_infraction(infraction["expires_at"]), - reason=( - "You have tried to change your nickname on the **Python Discord** server " - f"from **{before.display_name}** to **{after.display_name}**, but as you " - "are currently in superstar-prison, you do not have permission to do so." - ), - icon_url=_utils.INFRACTION_ICONS["superstar"][0] - ) - - if not notified: - log.info("Failed to DM user about why they cannot change their nickname.") - - @Cog.listener() - async def on_member_join(self, member: Member) -> None: - """Reapply active superstar infractions for returning members.""" - active_superstarifies = await self.bot.api_client.get( - "bot/infractions", - params={ - "active": "true", - "type": "superstar", - "user__id": member.id - } - ) - - if active_superstarifies: - infraction = active_superstarifies[0] - action = member.edit( - nick=self.get_nick(infraction["id"], member.id), - reason=f"Superstarified member tried to escape the prison: {infraction['id']}" - ) - - await self.reapply_infraction(infraction, action) - - @command(name="superstarify", aliases=("force_nick", "star")) - async def superstarify( - self, - ctx: Context, - member: Member, - duration: Expiry, - *, - reason: str = None, - ) -> None: - """ - Temporarily force a random superstar name (like Taylor Swift) to be the user's nickname. - - 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. - - An optional reason can be provided. If no reason is given, the original name will be shown - in a generated reason. - """ - if await _utils.get_active_infraction(ctx, member, "superstar"): - return - - # Post the infraction to the API - reason = reason or f"old nick: {member.display_name}" - infraction = await _utils.post_infraction(ctx, member, "superstar", reason, duration, active=True) - id_ = infraction["id"] - - old_nick = member.display_name - forced_nick = self.get_nick(id_, member.id) - expiry_str = format_infraction(infraction["expires_at"]) - - # Apply the infraction and schedule the expiration task. - log.debug(f"Changing nickname of {member} to {forced_nick}.") - self.mod_log.ignore(constants.Event.member_update, member.id) - await member.edit(nick=forced_nick, reason=reason) - self.schedule_expiration(infraction) - - # Send a DM to the user to notify them of their new infraction. - await _utils.notify_infraction( - user=member, - infr_type="Superstarify", - expires_at=expiry_str, - icon_url=_utils.INFRACTION_ICONS["superstar"][0], - reason=f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL})." - ) - - # Send an embed with the infraction information to the invoking context. - log.trace(f"Sending superstar #{id_} embed.") - embed = Embed( - title="Congratulations!", - colour=constants.Colours.soft_orange, - description=( - f"Your previous nickname, **{old_nick}**, " - f"was so bad that we have decided to change it. " - f"Your new nickname will be **{forced_nick}**.\n\n" - f"You will be unable to change your nickname until **{expiry_str}**.\n\n" - "If you're confused by this, please read our " - f"[official nickname policy]({NICKNAME_POLICY_URL})." - ) - ) - await ctx.send(embed=embed) - - # Log to the mod log channel. - log.trace(f"Sending apply mod log for superstar #{id_}.") - await self.mod_log.send_log_message( - icon_url=_utils.INFRACTION_ICONS["superstar"][0], - colour=Colour.gold(), - title="Member achieved superstardom", - thumbnail=member.avatar_url_as(static_format="png"), - text=textwrap.dedent(f""" - Member: {member.mention} (`{member.id}`) - Actor: {ctx.message.author} - Expires: {expiry_str} - Old nickname: `{old_nick}` - New nickname: `{forced_nick}` - Reason: {reason} - """), - footer=f"ID {id_}" - ) - - @command(name="unsuperstarify", aliases=("release_nick", "unstar")) - async def unsuperstarify(self, ctx: Context, member: Member) -> None: - """Remove the superstarify infraction and allow the user to change their nickname.""" - await self.pardon_infraction(ctx, "superstar", member) - - async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]: - """Pardon a superstar infraction and return a log dict.""" - if infraction["type"] != "superstar": - return - - guild = self.bot.get_guild(constants.Guild.id) - user = guild.get_member(infraction["user"]) - - # Don't bother sending a notification if the user left the guild. - if not user: - log.debug( - "User left the guild and therefore won't be notified about superstar " - f"{infraction['id']} pardon." - ) - return {} - - # DM the user about the expiration. - notified = await _utils.notify_pardon( - user=user, - title="You are no longer superstarified", - content="You may now change your nickname on the server.", - icon_url=_utils.INFRACTION_ICONS["superstar"][1] - ) - - return { - "Member": f"{user.mention}(`{user.id}`)", - "DM": "Sent" if notified else "**Failed**" - } - - @staticmethod - def get_nick(infraction_id: int, member_id: int) -> str: - """Randomly select a nickname from the Superstarify nickname list.""" - log.trace(f"Choosing a random nickname for superstar #{infraction_id}.") - - rng = random.Random(str(infraction_id) + str(member_id)) - return rng.choice(STAR_NAMES) - - # This cannot be static (must have a __func__ attribute). - def cog_check(self, ctx: Context) -> bool: - """Only allow moderators to invoke the commands in this cog.""" - return with_role_check(ctx, *constants.MODERATION_ROLES) - - -def setup(bot: Bot) -> None: - """Load the Superstarify cog.""" - bot.add_cog(Superstarify(bot)) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py deleted file mode 100644 index c86f04b9d..000000000 --- a/bot/cogs/moderation/modlog.py +++ /dev/null @@ -1,837 +0,0 @@ -import asyncio -import difflib -import itertools -import logging -import typing as t -from datetime import datetime -from itertools import zip_longest - -import discord -from dateutil.relativedelta import relativedelta -from deepdiff import DeepDiff -from discord import Colour -from discord.abc import GuildChannel -from discord.ext.commands import Cog, Context -from discord.utils import escape_markdown - -from bot.bot import Bot -from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs -from bot.utils.time import humanize_delta - -log = logging.getLogger(__name__) - -GUILD_CHANNEL = t.Union[discord.CategoryChannel, discord.TextChannel, discord.VoiceChannel] - -CHANNEL_CHANGES_UNSUPPORTED = ("permissions",) -CHANNEL_CHANGES_SUPPRESSED = ("_overwrites", "position") -ROLE_CHANGES_UNSUPPORTED = ("colour", "permissions") - -VOICE_STATE_ATTRIBUTES = { - "channel.name": "Channel", - "self_stream": "Streaming", - "self_video": "Broadcasting", -} - - -class ModLog(Cog, name="ModLog"): - """Logging for server events and staff actions.""" - - def __init__(self, bot: Bot): - self.bot = bot - self._ignored = {event: [] for event in Event} - - self._cached_deletes = [] - self._cached_edits = [] - - async def upload_log( - self, - messages: t.Iterable[discord.Message], - actor_id: int, - attachments: t.Iterable[t.List[str]] = None - ) -> str: - """Upload message logs to the database and return a URL to a page for viewing the logs.""" - if attachments is None: - attachments = [] - - response = await self.bot.api_client.post( - 'bot/deleted-messages', - json={ - 'actor': actor_id, - 'creation': datetime.utcnow().isoformat(), - 'deletedmessage_set': [ - { - 'id': message.id, - 'author': message.author.id, - 'channel_id': message.channel.id, - 'content': message.content, - 'embeds': [embed.to_dict() for embed in message.embeds], - 'attachments': attachment, - } - for message, attachment in zip_longest(messages, attachments, fillvalue=[]) - ] - } - ) - - return f"{URLs.site_logs_view}/{response['id']}" - - def ignore(self, event: Event, *items: int) -> None: - """Add event to ignored events to suppress log emission.""" - for item in items: - if item not in self._ignored[event]: - self._ignored[event].append(item) - - async def send_log_message( - self, - icon_url: t.Optional[str], - colour: t.Union[discord.Colour, int], - title: t.Optional[str], - text: str, - thumbnail: t.Optional[t.Union[str, discord.Asset]] = None, - channel_id: int = Channels.mod_log, - ping_everyone: bool = False, - files: t.Optional[t.List[discord.File]] = None, - content: t.Optional[str] = None, - additional_embeds: t.Optional[t.List[discord.Embed]] = None, - additional_embeds_msg: t.Optional[str] = None, - timestamp_override: t.Optional[datetime] = None, - footer: t.Optional[str] = None, - ) -> Context: - """Generate log embed and send to logging channel.""" - # Truncate string directly here to avoid removing newlines - embed = discord.Embed( - description=text[:2045] + "..." if len(text) > 2048 else text - ) - - if title and icon_url: - embed.set_author(name=title, icon_url=icon_url) - - embed.colour = colour - embed.timestamp = timestamp_override or datetime.utcnow() - - if footer: - embed.set_footer(text=footer) - - if thumbnail: - embed.set_thumbnail(url=thumbnail) - - if ping_everyone: - if content: - content = f"@everyone\n{content}" - else: - content = "@everyone" - - channel = self.bot.get_channel(channel_id) - log_message = await channel.send( - content=content, - embed=embed, - files=files, - allowed_mentions=discord.AllowedMentions(everyone=True) - ) - - if additional_embeds: - if additional_embeds_msg: - await channel.send(additional_embeds_msg) - for additional_embed in additional_embeds: - await channel.send(embed=additional_embed) - - return await self.bot.get_context(log_message) # Optionally return for use with antispam - - @Cog.listener() - async def on_guild_channel_create(self, channel: GUILD_CHANNEL) -> None: - """Log channel create event to mod log.""" - if channel.guild.id != GuildConstant.id: - return - - if isinstance(channel, discord.CategoryChannel): - title = "Category created" - message = f"{channel.name} (`{channel.id}`)" - elif isinstance(channel, discord.VoiceChannel): - title = "Voice channel created" - - if channel.category: - message = f"{channel.category}/{channel.name} (`{channel.id}`)" - else: - message = f"{channel.name} (`{channel.id}`)" - else: - title = "Text channel created" - - if channel.category: - message = f"{channel.category}/{channel.name} (`{channel.id}`)" - else: - message = f"{channel.name} (`{channel.id}`)" - - await self.send_log_message(Icons.hash_green, Colours.soft_green, title, message) - - @Cog.listener() - async def on_guild_channel_delete(self, channel: GUILD_CHANNEL) -> None: - """Log channel delete event to mod log.""" - if channel.guild.id != GuildConstant.id: - return - - if isinstance(channel, discord.CategoryChannel): - title = "Category deleted" - elif isinstance(channel, discord.VoiceChannel): - title = "Voice channel deleted" - else: - title = "Text channel deleted" - - if channel.category and not isinstance(channel, discord.CategoryChannel): - message = f"{channel.category}/{channel.name} (`{channel.id}`)" - else: - message = f"{channel.name} (`{channel.id}`)" - - await self.send_log_message( - Icons.hash_red, Colours.soft_red, - title, message - ) - - @Cog.listener() - async def on_guild_channel_update(self, before: GUILD_CHANNEL, after: GuildChannel) -> None: - """Log channel update event to mod log.""" - if before.guild.id != GuildConstant.id: - return - - if before.id in self._ignored[Event.guild_channel_update]: - self._ignored[Event.guild_channel_update].remove(before.id) - return - - # Two channel updates are sent for a single edit: 1 for topic and 1 for category change. - # TODO: remove once support is added for ignoring multiple occurrences for the same channel. - help_categories = (Categories.help_available, Categories.help_dormant, Categories.help_in_use) - if after.category and after.category.id in help_categories: - return - - diff = DeepDiff(before, after) - changes = [] - done = [] - - diff_values = diff.get("values_changed", {}) - diff_values.update(diff.get("type_changes", {})) - - for key, value in diff_values.items(): - if not key: # Not sure why, but it happens - continue - - key = key[5:] # Remove "root." prefix - - if "[" in key: - key = key.split("[", 1)[0] - - if "." in key: - key = key.split(".", 1)[0] - - if key in done or key in CHANNEL_CHANGES_SUPPRESSED: - continue - - if key in CHANNEL_CHANGES_UNSUPPORTED: - changes.append(f"**{key.title()}** updated") - else: - new = value["new_value"] - old = value["old_value"] - - # Discord does not treat consecutive backticks ("``") as an empty inline code block, so the markdown - # formatting is broken when `new` and/or `old` are empty values. "None" is used for these cases so - # formatting is preserved. - changes.append(f"**{key.title()}:** `{old or 'None'}` **→** `{new or 'None'}`") - - done.append(key) - - if not changes: - return - - message = "" - - for item in sorted(changes): - message += f"{Emojis.bullet} {item}\n" - - if after.category: - message = f"**{after.category}/#{after.name} (`{after.id}`)**\n{message}" - else: - message = f"**#{after.name}** (`{after.id}`)\n{message}" - - await self.send_log_message( - Icons.hash_blurple, Colour.blurple(), - "Channel updated", message - ) - - @Cog.listener() - async def on_guild_role_create(self, role: discord.Role) -> None: - """Log role create event to mod log.""" - if role.guild.id != GuildConstant.id: - return - - await self.send_log_message( - Icons.crown_green, Colours.soft_green, - "Role created", f"`{role.id}`" - ) - - @Cog.listener() - async def on_guild_role_delete(self, role: discord.Role) -> None: - """Log role delete event to mod log.""" - if role.guild.id != GuildConstant.id: - return - - await self.send_log_message( - Icons.crown_red, Colours.soft_red, - "Role removed", f"{role.name} (`{role.id}`)" - ) - - @Cog.listener() - async def on_guild_role_update(self, before: discord.Role, after: discord.Role) -> None: - """Log role update event to mod log.""" - if before.guild.id != GuildConstant.id: - return - - diff = DeepDiff(before, after) - changes = [] - done = [] - - diff_values = diff.get("values_changed", {}) - diff_values.update(diff.get("type_changes", {})) - - for key, value in diff_values.items(): - if not key: # Not sure why, but it happens - continue - - key = key[5:] # Remove "root." prefix - - if "[" in key: - key = key.split("[", 1)[0] - - if "." in key: - key = key.split(".", 1)[0] - - if key in done or key == "color": - continue - - if key in ROLE_CHANGES_UNSUPPORTED: - changes.append(f"**{key.title()}** updated") - else: - new = value["new_value"] - old = value["old_value"] - - changes.append(f"**{key.title()}:** `{old}` **→** `{new}`") - - done.append(key) - - if not changes: - return - - message = "" - - for item in sorted(changes): - message += f"{Emojis.bullet} {item}\n" - - message = f"**{after.name}** (`{after.id}`)\n{message}" - - await self.send_log_message( - Icons.crown_blurple, Colour.blurple(), - "Role updated", message - ) - - @Cog.listener() - async def on_guild_update(self, before: discord.Guild, after: discord.Guild) -> None: - """Log guild update event to mod log.""" - if before.id != GuildConstant.id: - return - - diff = DeepDiff(before, after) - changes = [] - done = [] - - diff_values = diff.get("values_changed", {}) - diff_values.update(diff.get("type_changes", {})) - - for key, value in diff_values.items(): - if not key: # Not sure why, but it happens - continue - - key = key[5:] # Remove "root." prefix - - if "[" in key: - key = key.split("[", 1)[0] - - if "." in key: - key = key.split(".", 1)[0] - - if key in done: - continue - - new = value["new_value"] - old = value["old_value"] - - changes.append(f"**{key.title()}:** `{old}` **→** `{new}`") - - done.append(key) - - if not changes: - return - - message = "" - - for item in sorted(changes): - message += f"{Emojis.bullet} {item}\n" - - message = f"**{after.name}** (`{after.id}`)\n{message}" - - await self.send_log_message( - Icons.guild_update, Colour.blurple(), - "Guild updated", message, - thumbnail=after.icon_url_as(format="png") - ) - - @Cog.listener() - async def on_member_ban(self, guild: discord.Guild, member: discord.Member) -> None: - """Log ban event to user log.""" - if guild.id != GuildConstant.id: - return - - if member.id in self._ignored[Event.member_ban]: - self._ignored[Event.member_ban].remove(member.id) - return - - await self.send_log_message( - Icons.user_ban, Colours.soft_red, - "User banned", f"{member} (`{member.id}`)", - thumbnail=member.avatar_url_as(static_format="png"), - channel_id=Channels.user_log - ) - - @Cog.listener() - async def on_member_join(self, member: discord.Member) -> None: - """Log member join event to user log.""" - if member.guild.id != GuildConstant.id: - return - - member_str = escape_markdown(str(member)) - message = f"{member_str} (`{member.id}`)" - now = datetime.utcnow() - difference = abs(relativedelta(now, member.created_at)) - - message += "\n\n**Account age:** " + humanize_delta(difference) - - if difference.days < 1 and difference.months < 1 and difference.years < 1: # New user account! - message = f"{Emojis.new} {message}" - - await self.send_log_message( - Icons.sign_in, Colours.soft_green, - "User joined", message, - thumbnail=member.avatar_url_as(static_format="png"), - channel_id=Channels.user_log - ) - - @Cog.listener() - async def on_member_remove(self, member: discord.Member) -> None: - """Log member leave event to user log.""" - if member.guild.id != GuildConstant.id: - return - - if member.id in self._ignored[Event.member_remove]: - self._ignored[Event.member_remove].remove(member.id) - return - - member_str = escape_markdown(str(member)) - await self.send_log_message( - Icons.sign_out, Colours.soft_red, - "User left", f"{member_str} (`{member.id}`)", - thumbnail=member.avatar_url_as(static_format="png"), - channel_id=Channels.user_log - ) - - @Cog.listener() - async def on_member_unban(self, guild: discord.Guild, member: discord.User) -> None: - """Log member unban event to mod log.""" - if guild.id != GuildConstant.id: - return - - if member.id in self._ignored[Event.member_unban]: - self._ignored[Event.member_unban].remove(member.id) - return - - member_str = escape_markdown(str(member)) - await self.send_log_message( - Icons.user_unban, Colour.blurple(), - "User unbanned", f"{member_str} (`{member.id}`)", - thumbnail=member.avatar_url_as(static_format="png"), - channel_id=Channels.mod_log - ) - - @staticmethod - def get_role_diff(before: t.List[discord.Role], after: t.List[discord.Role]) -> t.List[str]: - """Return a list of strings describing the roles added and removed.""" - changes = [] - before_roles = set(before) - after_roles = set(after) - - for role in (before_roles - after_roles): - changes.append(f"**Role removed:** {role.name} (`{role.id}`)") - - for role in (after_roles - before_roles): - changes.append(f"**Role added:** {role.name} (`{role.id}`)") - - return changes - - @Cog.listener() - async def on_member_update(self, before: discord.Member, after: discord.Member) -> None: - """Log member update event to user log.""" - if before.guild.id != GuildConstant.id: - return - - if before.id in self._ignored[Event.member_update]: - self._ignored[Event.member_update].remove(before.id) - return - - changes = self.get_role_diff(before.roles, after.roles) - - # The regex is a simple way to exclude all sequence and mapping types. - diff = DeepDiff(before, after, exclude_regex_paths=r".*\[.*") - - # A type change seems to always take precedent over a value change. Furthermore, it will - # include the value change along with the type change anyway. Therefore, it's OK to - # "overwrite" values_changed; in practice there will never even be anything to overwrite. - diff_values = {**diff.get("values_changed", {}), **diff.get("type_changes", {})} - - for attr, value in diff_values.items(): - if not attr: # Not sure why, but it happens. - continue - - attr = attr[5:] # Remove "root." prefix. - attr = attr.replace("_", " ").replace(".", " ").capitalize() - - new = value.get("new_value") - old = value.get("old_value") - - changes.append(f"**{attr}:** `{old}` **→** `{new}`") - - if not changes: - return - - message = "" - - for item in sorted(changes): - message += f"{Emojis.bullet} {item}\n" - - member_str = escape_markdown(str(after)) - message = f"**{member_str}** (`{after.id}`)\n{message}" - - await self.send_log_message( - icon_url=Icons.user_update, - colour=Colour.blurple(), - title="Member updated", - text=message, - thumbnail=after.avatar_url_as(static_format="png"), - channel_id=Channels.user_log - ) - - @Cog.listener() - async def on_message_delete(self, message: discord.Message) -> None: - """Log message delete event to message change log.""" - channel = message.channel - author = message.author - - # Ignore DMs. - if not message.guild: - return - - if message.guild.id != GuildConstant.id or channel.id in GuildConstant.modlog_blacklist: - return - - self._cached_deletes.append(message.id) - - if message.id in self._ignored[Event.message_delete]: - self._ignored[Event.message_delete].remove(message.id) - return - - if author.bot: - return - - author_str = escape_markdown(str(author)) - if channel.category: - response = ( - f"**Author:** {author_str} (`{author.id}`)\n" - f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" - f"**Message ID:** `{message.id}`\n" - "\n" - ) - else: - response = ( - f"**Author:** {author_str} (`{author.id}`)\n" - f"**Channel:** #{channel.name} (`{channel.id}`)\n" - f"**Message ID:** `{message.id}`\n" - "\n" - ) - - if message.attachments: - # Prepend the message metadata with the number of attachments - response = f"**Attachments:** {len(message.attachments)}\n" + response - - # Shorten the message content if necessary - content = message.clean_content - remaining_chars = 2040 - len(response) - - if len(content) > remaining_chars: - botlog_url = await self.upload_log(messages=[message], actor_id=message.author.id) - ending = f"\n\nMessage truncated, [full message here]({botlog_url})." - truncation_point = remaining_chars - len(ending) - content = f"{content[:truncation_point]}...{ending}" - - response += f"{content}" - - await self.send_log_message( - Icons.message_delete, Colours.soft_red, - "Message deleted", - response, - channel_id=Channels.message_log - ) - - @Cog.listener() - async def on_raw_message_delete(self, event: discord.RawMessageDeleteEvent) -> None: - """Log raw message delete event to message change log.""" - if event.guild_id != GuildConstant.id or event.channel_id in GuildConstant.modlog_blacklist: - return - - await asyncio.sleep(1) # Wait here in case the normal event was fired - - if event.message_id in self._cached_deletes: - # It was in the cache and the normal event was fired, so we can just ignore it - self._cached_deletes.remove(event.message_id) - return - - if event.message_id in self._ignored[Event.message_delete]: - self._ignored[Event.message_delete].remove(event.message_id) - return - - channel = self.bot.get_channel(event.channel_id) - - if channel.category: - response = ( - f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" - f"**Message ID:** `{event.message_id}`\n" - "\n" - "This message was not cached, so the message content cannot be displayed." - ) - else: - response = ( - f"**Channel:** #{channel.name} (`{channel.id}`)\n" - f"**Message ID:** `{event.message_id}`\n" - "\n" - "This message was not cached, so the message content cannot be displayed." - ) - - await self.send_log_message( - Icons.message_delete, Colours.soft_red, - "Message deleted", - response, - channel_id=Channels.message_log - ) - - @Cog.listener() - async def on_message_edit(self, msg_before: discord.Message, msg_after: discord.Message) -> None: - """Log message edit event to message change log.""" - if ( - not msg_before.guild - or msg_before.guild.id != GuildConstant.id - or msg_before.channel.id in GuildConstant.modlog_blacklist - or msg_before.author.bot - ): - return - - self._cached_edits.append(msg_before.id) - - if msg_before.content == msg_after.content: - return - - author = msg_before.author - author_str = escape_markdown(str(author)) - - channel = msg_before.channel - channel_name = f"{channel.category}/#{channel.name}" if channel.category else f"#{channel.name}" - - # Getting the difference per words and group them by type - add, remove, same - # Note that this is intended grouping without sorting - diff = difflib.ndiff(msg_before.clean_content.split(), msg_after.clean_content.split()) - diff_groups = tuple( - (diff_type, tuple(s[2:] for s in diff_words)) - for diff_type, diff_words in itertools.groupby(diff, key=lambda s: s[0]) - ) - - content_before: t.List[str] = [] - content_after: t.List[str] = [] - - for index, (diff_type, words) in enumerate(diff_groups): - sub = ' '.join(words) - if diff_type == '-': - content_before.append(f"[{sub}](http://o.hi)") - elif diff_type == '+': - content_after.append(f"[{sub}](http://o.hi)") - elif diff_type == ' ': - if len(words) > 2: - sub = ( - f"{words[0] if index > 0 else ''}" - " ... " - f"{words[-1] if index < len(diff_groups) - 1 else ''}" - ) - content_before.append(sub) - content_after.append(sub) - - response = ( - f"**Author:** {author_str} (`{author.id}`)\n" - f"**Channel:** {channel_name} (`{channel.id}`)\n" - f"**Message ID:** `{msg_before.id}`\n" - "\n" - f"**Before**:\n{' '.join(content_before)}\n" - f"**After**:\n{' '.join(content_after)}\n" - "\n" - f"[Jump to message]({msg_after.jump_url})" - ) - - if msg_before.edited_at: - # Message was previously edited, to assist with self-bot detection, use the edited_at - # datetime as the baseline and create a human-readable delta between this edit event - # and the last time the message was edited - timestamp = msg_before.edited_at - delta = humanize_delta(relativedelta(msg_after.edited_at, msg_before.edited_at)) - footer = f"Last edited {delta} ago" - else: - # Message was not previously edited, use the created_at datetime as the baseline, no - # delta calculation needed - timestamp = msg_before.created_at - footer = None - - await self.send_log_message( - Icons.message_edit, Colour.blurple(), "Message edited", response, - channel_id=Channels.message_log, timestamp_override=timestamp, footer=footer - ) - - @Cog.listener() - async def on_raw_message_edit(self, event: discord.RawMessageUpdateEvent) -> None: - """Log raw message edit event to message change log.""" - try: - channel = self.bot.get_channel(int(event.data["channel_id"])) - message = await channel.fetch_message(event.message_id) - except discord.NotFound: # Was deleted before we got the event - return - - if ( - not message.guild - or message.guild.id != GuildConstant.id - or message.channel.id in GuildConstant.modlog_blacklist - or message.author.bot - ): - return - - await asyncio.sleep(1) # Wait here in case the normal event was fired - - if event.message_id in self._cached_edits: - # It was in the cache and the normal event was fired, so we can just ignore it - self._cached_edits.remove(event.message_id) - return - - author = message.author - channel = message.channel - channel_name = f"{channel.category}/#{channel.name}" if channel.category else f"#{channel.name}" - - before_response = ( - f"**Author:** {author} (`{author.id}`)\n" - f"**Channel:** {channel_name} (`{channel.id}`)\n" - f"**Message ID:** `{message.id}`\n" - "\n" - "This message was not cached, so the message content cannot be displayed." - ) - - after_response = ( - f"**Author:** {author} (`{author.id}`)\n" - f"**Channel:** {channel_name} (`{channel.id}`)\n" - f"**Message ID:** `{message.id}`\n" - "\n" - f"{message.clean_content}" - ) - - await self.send_log_message( - Icons.message_edit, Colour.blurple(), "Message edited (Before)", - before_response, channel_id=Channels.message_log - ) - - await self.send_log_message( - Icons.message_edit, Colour.blurple(), "Message edited (After)", - after_response, channel_id=Channels.message_log - ) - - @Cog.listener() - async def on_voice_state_update( - self, - member: discord.Member, - before: discord.VoiceState, - after: discord.VoiceState - ) -> None: - """Log member voice state changes to the voice log channel.""" - if ( - member.guild.id != GuildConstant.id - or (before.channel and before.channel.id in GuildConstant.modlog_blacklist) - ): - return - - if member.id in self._ignored[Event.voice_state_update]: - self._ignored[Event.voice_state_update].remove(member.id) - return - - # Exclude all channel attributes except the name. - diff = DeepDiff( - before, - after, - exclude_paths=("root.session_id", "root.afk"), - exclude_regex_paths=r"root\.channel\.(?!name)", - ) - - # A type change seems to always take precedent over a value change. Furthermore, it will - # include the value change along with the type change anyway. Therefore, it's OK to - # "overwrite" values_changed; in practice there will never even be anything to overwrite. - diff_values = {**diff.get("values_changed", {}), **diff.get("type_changes", {})} - - icon = Icons.voice_state_blue - colour = Colour.blurple() - changes = [] - - for attr, values in diff_values.items(): - if not attr: # Not sure why, but it happens. - continue - - old = values["old_value"] - new = values["new_value"] - - attr = attr[5:] # Remove "root." prefix. - attr = VOICE_STATE_ATTRIBUTES.get(attr, attr.replace("_", " ").capitalize()) - - changes.append(f"**{attr}:** `{old}` **→** `{new}`") - - # Set the embed icon and colour depending on which attribute changed. - if any(name in attr for name in ("Channel", "deaf", "mute")): - if new is None or new is True: - # Left a channel or was muted/deafened. - icon = Icons.voice_state_red - colour = Colours.soft_red - elif old is None or old is True: - # Joined a channel or was unmuted/undeafened. - icon = Icons.voice_state_green - colour = Colours.soft_green - - if not changes: - return - - member_str = escape_markdown(str(member)) - message = "\n".join(f"{Emojis.bullet} {item}" for item in sorted(changes)) - message = f"**{member_str}** (`{member.id}`)\n{message}" - - await self.send_log_message( - icon_url=icon, - colour=colour, - title="Voice state updated", - text=message, - thumbnail=member.avatar_url_as(static_format="png"), - channel_id=Channels.voice_log - ) - - -def setup(bot: Bot) -> None: - """Load the ModLog cog.""" - bot.add_cog(ModLog(bot)) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py deleted file mode 100644 index 4af87c724..000000000 --- a/bot/cogs/moderation/silence.py +++ /dev/null @@ -1,170 +0,0 @@ -import asyncio -import logging -from contextlib import suppress -from typing import Optional - -from discord import TextChannel -from discord.ext import commands, tasks -from discord.ext.commands import Context - -from bot.bot import Bot -from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles -from bot.converters import HushDurationConverter -from bot.utils.checks import with_role_check -from bot.utils.scheduling import Scheduler - -log = logging.getLogger(__name__) - - -class SilenceNotifier(tasks.Loop): - """Loop notifier for posting notices to `alert_channel` containing added channels.""" - - def __init__(self, alert_channel: TextChannel): - super().__init__(self._notifier, seconds=1, minutes=0, hours=0, count=None, reconnect=True, loop=None) - self._silenced_channels = {} - self._alert_channel = alert_channel - - def add_channel(self, channel: TextChannel) -> None: - """Add channel to `_silenced_channels` and start loop if not launched.""" - if not self._silenced_channels: - self.start() - log.info("Starting notifier loop.") - self._silenced_channels[channel] = self._current_loop - - def remove_channel(self, channel: TextChannel) -> None: - """Remove channel from `_silenced_channels` and stop loop if no channels remain.""" - with suppress(KeyError): - del self._silenced_channels[channel] - if not self._silenced_channels: - self.stop() - log.info("Stopping notifier loop.") - - async def _notifier(self) -> None: - """Post notice of `_silenced_channels` with their silenced duration to `_alert_channel` periodically.""" - # Wait for 15 minutes between notices with pause at start of loop. - if self._current_loop and not self._current_loop/60 % 15: - log.debug( - f"Sending notice with channels: " - f"{', '.join(f'#{channel} ({channel.id})' for channel in self._silenced_channels)}." - ) - channels_text = ', '.join( - f"{channel.mention} for {(self._current_loop-start)//60} min" - for channel, start in self._silenced_channels.items() - ) - await self._alert_channel.send(f"<@&{Roles.moderators}> currently silenced channels: {channels_text}") - - -class Silence(commands.Cog): - """Commands for stopping channel messages for `verified` role in a channel.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.scheduler = Scheduler(self.__class__.__name__) - self.muted_channels = set() - - self._get_instance_vars_task = self.bot.loop.create_task(self._get_instance_vars()) - self._get_instance_vars_event = asyncio.Event() - - async def _get_instance_vars(self) -> None: - """Get instance variables after they're available to get from the guild.""" - await self.bot.wait_until_guild_available() - guild = self.bot.get_guild(Guild.id) - self._verified_role = guild.get_role(Roles.verified) - self._mod_alerts_channel = self.bot.get_channel(Channels.mod_alerts) - self._mod_log_channel = self.bot.get_channel(Channels.mod_log) - self.notifier = SilenceNotifier(self._mod_log_channel) - self._get_instance_vars_event.set() - - @commands.command(aliases=("hush",)) - async def silence(self, ctx: Context, duration: HushDurationConverter = 10) -> None: - """ - Silence the current channel for `duration` minutes or `forever`. - - Duration is capped at 15 minutes, passing forever makes the silence indefinite. - Indefinitely silenced channels get added to a notifier which posts notices every 15 minutes from the start. - """ - await self._get_instance_vars_event.wait() - log.debug(f"{ctx.author} is silencing channel #{ctx.channel}.") - if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration): - await ctx.send(f"{Emojis.cross_mark} current channel is already silenced.") - return - if duration is None: - await ctx.send(f"{Emojis.check_mark} silenced current channel indefinitely.") - return - - await ctx.send(f"{Emojis.check_mark} silenced current channel for {duration} minute(s).") - - self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence)) - - @commands.command(aliases=("unhush",)) - async def unsilence(self, ctx: Context) -> None: - """ - Unsilence the current channel. - - If the channel was silenced indefinitely, notifications for the channel will stop. - """ - await self._get_instance_vars_event.wait() - log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") - if not await self._unsilence(ctx.channel): - await ctx.send(f"{Emojis.cross_mark} current channel was not silenced.") - else: - await ctx.send(f"{Emojis.check_mark} unsilenced current channel.") - - async def _silence(self, channel: TextChannel, persistent: bool, duration: Optional[int]) -> bool: - """ - Silence `channel` for `self._verified_role`. - - If `persistent` is `True` add `channel` to notifier. - `duration` is only used for logging; if None is passed `persistent` should be True to not log None. - Return `True` if channel permissions were changed, `False` otherwise. - """ - current_overwrite = channel.overwrites_for(self._verified_role) - if current_overwrite.send_messages is False: - log.info(f"Tried to silence channel #{channel} ({channel.id}) but the channel was already silenced.") - return False - await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=False)) - self.muted_channels.add(channel) - if persistent: - log.info(f"Silenced #{channel} ({channel.id}) indefinitely.") - self.notifier.add_channel(channel) - return True - - log.info(f"Silenced #{channel} ({channel.id}) for {duration} minute(s).") - return True - - async def _unsilence(self, channel: TextChannel) -> bool: - """ - Unsilence `channel`. - - Check if `channel` is silenced through a `PermissionOverwrite`, - if it is unsilence it and remove it from the notifier. - Return `True` if channel permissions were changed, `False` otherwise. - """ - current_overwrite = channel.overwrites_for(self._verified_role) - if current_overwrite.send_messages is False: - await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=None)) - log.info(f"Unsilenced channel #{channel} ({channel.id}).") - self.scheduler.cancel(channel.id) - self.notifier.remove_channel(channel) - self.muted_channels.discard(channel) - return True - log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") - return False - - def cog_unload(self) -> None: - """Send alert with silenced channels and cancel scheduled tasks on unload.""" - self.scheduler.cancel_all() - if self.muted_channels: - channels_string = ''.join(channel.mention for channel in self.muted_channels) - message = f"<@&{Roles.moderators}> channels left silenced on cog unload: {channels_string}" - asyncio.create_task(self._mod_alerts_channel.send(message)) - - # This cannot be static (must have a __func__ attribute). - def cog_check(self, ctx: Context) -> bool: - """Only allow moderators to invoke the commands in this cog.""" - return with_role_check(ctx, *MODERATION_ROLES) - - -def setup(bot: Bot) -> None: - """Load the Silence cog.""" - bot.add_cog(Silence(bot)) diff --git a/bot/cogs/moderation/slowmode.py b/bot/cogs/moderation/slowmode.py deleted file mode 100644 index 1d055afac..000000000 --- a/bot/cogs/moderation/slowmode.py +++ /dev/null @@ -1,97 +0,0 @@ -import logging -from datetime import datetime -from typing import Optional - -from dateutil.relativedelta import relativedelta -from discord import TextChannel -from discord.ext.commands import Cog, Context, group - -from bot.bot import Bot -from bot.constants import Emojis, MODERATION_ROLES -from bot.converters import DurationDelta -from bot.decorators import with_role_check -from bot.utils import time - -log = logging.getLogger(__name__) - -SLOWMODE_MAX_DELAY = 21600 # seconds - - -class Slowmode(Cog): - """Commands for getting and setting slowmode delays of text channels.""" - - def __init__(self, bot: Bot) -> None: - self.bot = bot - - @group(name='slowmode', aliases=['sm'], invoke_without_command=True) - async def slowmode_group(self, ctx: Context) -> None: - """Get or set the slowmode delay for the text channel this was invoked in or a given text channel.""" - await ctx.send_help(ctx.command) - - @slowmode_group.command(name='get', aliases=['g']) - async def get_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None: - """Get the slowmode delay for a text channel.""" - # Use the channel this command was invoked in if one was not given - if channel is None: - channel = ctx.channel - - delay = relativedelta(seconds=channel.slowmode_delay) - humanized_delay = time.humanize_delta(delay) - - await ctx.send(f'The slowmode delay for {channel.mention} is {humanized_delay}.') - - @slowmode_group.command(name='set', aliases=['s']) - async def set_slowmode(self, ctx: Context, channel: Optional[TextChannel], delay: DurationDelta) -> None: - """Set the slowmode delay for a text channel.""" - # Use the channel this command was invoked in if one was not given - if channel is None: - channel = ctx.channel - - # Convert `dateutil.relativedelta.relativedelta` to `datetime.timedelta` - # Must do this to get the delta in a particular unit of time - utcnow = datetime.utcnow() - slowmode_delay = (utcnow + delay - utcnow).total_seconds() - - humanized_delay = time.humanize_delta(delay) - - # Ensure the delay is within discord's limits - if slowmode_delay <= SLOWMODE_MAX_DELAY: - log.info(f'{ctx.author} set the slowmode delay for #{channel} to {humanized_delay}.') - - await channel.edit(slowmode_delay=slowmode_delay) - await ctx.send( - f'{Emojis.check_mark} The slowmode delay for {channel.mention} is now {humanized_delay}.' - ) - - else: - log.info( - f'{ctx.author} tried to set the slowmode delay of #{channel} to {humanized_delay}, ' - 'which is not between 0 and 6 hours.' - ) - - await ctx.send( - f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.' - ) - - @slowmode_group.command(name='reset', aliases=['r']) - async def reset_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None: - """Reset the slowmode delay for a text channel to 0 seconds.""" - # Use the channel this command was invoked in if one was not given - if channel is None: - channel = ctx.channel - - log.info(f'{ctx.author} reset the slowmode delay for #{channel} to 0 seconds.') - - await channel.edit(slowmode_delay=0) - await ctx.send( - f'{Emojis.check_mark} The slowmode delay for {channel.mention} has been reset to 0 seconds.' - ) - - def cog_check(self, ctx: Context) -> bool: - """Only allow moderators to invoke the commands in this cog.""" - return with_role_check(ctx, *MODERATION_ROLES) - - -def setup(bot: Bot) -> None: - """Load the Slowmode cog.""" - bot.add_cog(Slowmode(bot)) diff --git a/bot/cogs/moderation/verification.py b/bot/cogs/moderation/verification.py deleted file mode 100644 index ba95ab5e4..000000000 --- a/bot/cogs/moderation/verification.py +++ /dev/null @@ -1,191 +0,0 @@ -import logging -from contextlib import suppress - -from discord import Colour, Forbidden, Message, NotFound, Object -from discord.ext.commands import Cog, Context, command - -from bot import constants -from bot.bot import Bot -from bot.cogs.moderation.modlog import ModLog -from bot.decorators import in_whitelist, without_role -from bot.utils.checks import InWhitelistCheckFailure, without_role_check - -log = logging.getLogger(__name__) - -WELCOME_MESSAGE = f""" -Hello! Welcome to the server, and thanks for verifying yourself! - -For your records, these are the documents you accepted: - -`1)` Our rules, here: -`2)` Our privacy policy, here: - you can find information on how to have \ -your information removed here as well. - -Feel free to review them at any point! - -Additionally, if you'd like to receive notifications for the announcements \ -we post in <#{constants.Channels.announcements}> -from time to time, you can send `!subscribe` to <#{constants.Channels.bot_commands}> at any time \ -to assign yourself the **Announcements** role. We'll mention this role every time we make an announcement. - -If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to \ -<#{constants.Channels.bot_commands}>. -""" - -BOT_MESSAGE_DELETE_DELAY = 10 - - -class Verification(Cog): - """User verification and role self-management.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - @Cog.listener() - async def on_message(self, message: Message) -> None: - """Check new message event for messages to the checkpoint channel & process.""" - if message.channel.id != constants.Channels.verification: - return # Only listen for #checkpoint messages - - if message.author.bot: - # They're a bot, delete their message after the delay. - await message.delete(delay=BOT_MESSAGE_DELETE_DELAY) - return - - # if a user mentions a role or guild member - # alert the mods in mod-alerts channel - if message.mentions or message.role_mentions: - log.debug( - f"{message.author} mentioned one or more users " - f"and/or roles in {message.channel.name}" - ) - - embed_text = ( - f"{message.author.mention} sent a message in " - f"{message.channel.mention} that contained user and/or role mentions." - f"\n\n**Original message:**\n>>> {message.content}" - ) - - # Send pretty mod log embed to mod-alerts - await self.mod_log.send_log_message( - icon_url=constants.Icons.filtering, - colour=Colour(constants.Colours.soft_red), - title=f"User/Role mentioned in {message.channel.name}", - text=embed_text, - thumbnail=message.author.avatar_url_as(static_format="png"), - channel_id=constants.Channels.mod_alerts, - ) - - ctx: Context = await self.bot.get_context(message) - if ctx.command is not None and ctx.command.name == "accept": - return - - if any(r.id == constants.Roles.verified for r in ctx.author.roles): - log.info( - f"{ctx.author} posted '{ctx.message.content}' " - "in the verification channel, but is already verified." - ) - return - - log.debug( - f"{ctx.author} posted '{ctx.message.content}' in the verification " - "channel. We are providing instructions how to verify." - ) - await ctx.send( - f"{ctx.author.mention} Please type `!accept` to verify that you accept our rules, " - f"and gain access to the rest of the server.", - delete_after=20 - ) - - log.trace(f"Deleting the message posted by {ctx.author}") - with suppress(NotFound): - await ctx.message.delete() - - @command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True) - @without_role(constants.Roles.verified) - @in_whitelist(channels=(constants.Channels.verification,)) - async def accept_command(self, ctx: Context, *_) -> None: # We don't actually care about the args - """Accept our rules and gain access to the rest of the server.""" - log.debug(f"{ctx.author} called !accept. Assigning the 'Developer' role.") - await ctx.author.add_roles(Object(constants.Roles.verified), reason="Accepted the rules") - try: - await ctx.author.send(WELCOME_MESSAGE) - except Forbidden: - log.info(f"Sending welcome message failed for {ctx.author}.") - finally: - log.trace(f"Deleting accept message by {ctx.author}.") - with suppress(NotFound): - self.mod_log.ignore(constants.Event.message_delete, ctx.message.id) - await ctx.message.delete() - - @command(name='subscribe') - @in_whitelist(channels=(constants.Channels.bot_commands,)) - async def subscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args - """Subscribe to announcement notifications by assigning yourself the role.""" - has_role = False - - for role in ctx.author.roles: - if role.id == constants.Roles.announcements: - has_role = True - break - - if has_role: - await ctx.send(f"{ctx.author.mention} You're already subscribed!") - return - - log.debug(f"{ctx.author} called !subscribe. Assigning the 'Announcements' role.") - await ctx.author.add_roles(Object(constants.Roles.announcements), reason="Subscribed to announcements") - - log.trace(f"Deleting the message posted by {ctx.author}.") - - await ctx.send( - f"{ctx.author.mention} Subscribed to <#{constants.Channels.announcements}> notifications.", - ) - - @command(name='unsubscribe') - @in_whitelist(channels=(constants.Channels.bot_commands,)) - async def unsubscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args - """Unsubscribe from announcement notifications by removing the role from yourself.""" - has_role = False - - for role in ctx.author.roles: - if role.id == constants.Roles.announcements: - has_role = True - break - - if not has_role: - await ctx.send(f"{ctx.author.mention} You're already unsubscribed!") - return - - log.debug(f"{ctx.author} called !unsubscribe. Removing the 'Announcements' role.") - await ctx.author.remove_roles(Object(constants.Roles.announcements), reason="Unsubscribed from announcements") - - log.trace(f"Deleting the message posted by {ctx.author}.") - - await ctx.send( - f"{ctx.author.mention} Unsubscribed from <#{constants.Channels.announcements}> notifications." - ) - - # This cannot be static (must have a __func__ attribute). - async def cog_command_error(self, ctx: Context, error: Exception) -> None: - """Check for & ignore any InWhitelistCheckFailure.""" - if isinstance(error, InWhitelistCheckFailure): - error.handled = True - - @staticmethod - def bot_check(ctx: Context) -> bool: - """Block any command within the verification channel that is not !accept.""" - if ctx.channel.id == constants.Channels.verification and without_role_check(ctx, *constants.MODERATION_ROLES): - return ctx.command.name == "accept" - else: - return True - - -def setup(bot: Bot) -> None: - """Load the Verification cog.""" - bot.add_cog(Verification(bot)) diff --git a/bot/cogs/moderation/watchchannels/__init__.py b/bot/cogs/moderation/watchchannels/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/bot/cogs/moderation/watchchannels/_watchchannel.py b/bot/cogs/moderation/watchchannels/_watchchannel.py deleted file mode 100644 index 488ae704d..000000000 --- a/bot/cogs/moderation/watchchannels/_watchchannel.py +++ /dev/null @@ -1,348 +0,0 @@ -import asyncio -import logging -import re -import textwrap -from abc import abstractmethod -from collections import defaultdict, deque -from dataclasses import dataclass -from typing import Optional - -import dateutil.parser -import discord -from discord import Color, DMChannel, Embed, HTTPException, Message, errors -from discord.ext.commands import Cog, Context - -from bot.api import ResponseCodeError -from bot.bot import Bot -from bot.cogs.moderation.modlog import ModLog -from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons -from bot.pagination import LinePaginator -from bot.utils import CogABCMeta, messages -from bot.utils.time import time_since - -log = logging.getLogger(__name__) - -URL_RE = re.compile(r"(https?://[^\s]+)") - - -@dataclass -class MessageHistory: - """Represents a watch channel's message history.""" - - last_author: Optional[int] = None - last_channel: Optional[int] = None - message_count: int = 0 - - -class WatchChannel(metaclass=CogABCMeta): - """ABC with functionality for relaying users' messages to a certain channel.""" - - @abstractmethod - def __init__( - self, - bot: Bot, - destination: int, - webhook_id: int, - api_endpoint: str, - api_default_params: dict, - logger: logging.Logger - ) -> None: - self.bot = bot - - self.destination = destination # E.g., Channels.big_brother_logs - self.webhook_id = webhook_id # E.g., Webhooks.big_brother - self.api_endpoint = api_endpoint # E.g., 'bot/infractions' - self.api_default_params = api_default_params # E.g., {'active': 'true', 'type': 'watch'} - self.log = logger # Logger of the child cog for a correct name in the logs - - self._consume_task = None - self.watched_users = defaultdict(dict) - self.message_queue = defaultdict(lambda: defaultdict(deque)) - self.consumption_queue = {} - self.retries = 5 - self.retry_delay = 10 - self.channel = None - self.webhook = None - self.message_history = MessageHistory() - - self._start = self.bot.loop.create_task(self.start_watchchannel()) - - @property - def modlog(self) -> ModLog: - """Provides access to the ModLog cog for alert purposes.""" - return self.bot.get_cog("ModLog") - - @property - def consuming_messages(self) -> bool: - """Checks if a consumption task is currently running.""" - if self._consume_task is None: - return False - - if self._consume_task.done(): - exc = self._consume_task.exception() - if exc: - self.log.exception( - "The message queue consume task has failed with:", - exc_info=exc - ) - return False - - return True - - async def start_watchchannel(self) -> None: - """Starts the watch channel by getting the channel, webhook, and user cache ready.""" - await self.bot.wait_until_guild_available() - - try: - self.channel = await self.bot.fetch_channel(self.destination) - except HTTPException: - self.log.exception(f"Failed to retrieve the text channel with id `{self.destination}`") - - try: - self.webhook = await self.bot.fetch_webhook(self.webhook_id) - except discord.HTTPException: - self.log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") - - if self.channel is None or self.webhook is None: - self.log.error("Failed to start the watch channel; unloading the cog.") - - message = textwrap.dedent( - f""" - An error occurred while loading the text channel or webhook. - - TextChannel: {"**Failed to load**" if self.channel is None else "Loaded successfully"} - Webhook: {"**Failed to load**" if self.webhook is None else "Loaded successfully"} - - The Cog has been unloaded. - """ - ) - - await self.modlog.send_log_message( - title=f"Error: Failed to initialize the {self.__class__.__name__} watch channel", - text=message, - ping_everyone=True, - icon_url=Icons.token_removed, - colour=Color.red() - ) - - self.bot.remove_cog(self.__class__.__name__) - return - - if not await self.fetch_user_cache(): - await self.modlog.send_log_message( - title=f"Warning: Failed to retrieve user cache for the {self.__class__.__name__} watch channel", - text="Could not retrieve the list of watched users from the API and messages will not be relayed.", - ping_everyone=True, - icon_url=Icons.token_removed, - colour=Color.red() - ) - - async def fetch_user_cache(self) -> bool: - """ - Fetches watched users from the API and updates the watched user cache accordingly. - - This function returns `True` if the update succeeded. - """ - try: - data = await self.bot.api_client.get(self.api_endpoint, params=self.api_default_params) - except ResponseCodeError as err: - self.log.exception("Failed to fetch the watched users from the API", exc_info=err) - return False - - self.watched_users = defaultdict(dict) - - for entry in data: - user_id = entry.pop('user') - self.watched_users[user_id] = entry - - return True - - @Cog.listener() - async def on_message(self, msg: Message) -> None: - """Queues up messages sent by watched users.""" - if msg.author.id in self.watched_users: - if not self.consuming_messages: - self._consume_task = self.bot.loop.create_task(self.consume_messages()) - - self.log.trace(f"Received message: {msg.content} ({len(msg.attachments)} attachments)") - self.message_queue[msg.author.id][msg.channel.id].append(msg) - - async def consume_messages(self, delay_consumption: bool = True) -> None: - """Consumes the message queues to log watched users' messages.""" - if delay_consumption: - self.log.trace(f"Sleeping {BigBrotherConfig.log_delay} seconds before consuming message queue") - await asyncio.sleep(BigBrotherConfig.log_delay) - - self.log.trace("Started consuming the message queue") - - # If the previous consumption Task failed, first consume the existing comsumption_queue - if not self.consumption_queue: - self.consumption_queue = self.message_queue.copy() - self.message_queue.clear() - - for user_channel_queues in self.consumption_queue.values(): - for channel_queue in user_channel_queues.values(): - while channel_queue: - msg = channel_queue.popleft() - - self.log.trace(f"Consuming message {msg.id} ({len(msg.attachments)} attachments)") - await self.relay_message(msg) - - self.consumption_queue.clear() - - if self.message_queue: - self.log.trace("Channel queue not empty: Continuing consuming queues") - self._consume_task = self.bot.loop.create_task(self.consume_messages(delay_consumption=False)) - else: - self.log.trace("Done consuming messages.") - - async def webhook_send( - self, - content: Optional[str] = None, - username: Optional[str] = None, - avatar_url: Optional[str] = None, - embed: Optional[Embed] = None, - ) -> None: - """Sends a message to the webhook with the specified kwargs.""" - username = messages.sub_clyde(username) - try: - await self.webhook.send(content=content, username=username, avatar_url=avatar_url, embed=embed) - except discord.HTTPException as exc: - self.log.exception( - "Failed to send a message to the webhook", - exc_info=exc - ) - - async def relay_message(self, msg: Message) -> None: - """Relays the message to the relevant watch channel.""" - limit = BigBrotherConfig.header_message_limit - - if ( - msg.author.id != self.message_history.last_author - or msg.channel.id != self.message_history.last_channel - or self.message_history.message_count >= limit - ): - self.message_history = MessageHistory(last_author=msg.author.id, last_channel=msg.channel.id) - - await self.send_header(msg) - - cleaned_content = msg.clean_content - - if cleaned_content: - # Put all non-media URLs in a code block to prevent embeds - media_urls = {embed.url for embed in msg.embeds if embed.type in ("image", "video")} - for url in URL_RE.findall(cleaned_content): - if url not in media_urls: - cleaned_content = cleaned_content.replace(url, f"`{url}`") - await self.webhook_send( - cleaned_content, - username=msg.author.display_name, - avatar_url=msg.author.avatar_url - ) - - if msg.attachments: - try: - await messages.send_attachments(msg, self.webhook) - except (errors.Forbidden, errors.NotFound): - e = Embed( - description=":x: **This message contained an attachment, but it could not be retrieved**", - color=Color.red() - ) - await self.webhook_send( - embed=e, - username=msg.author.display_name, - avatar_url=msg.author.avatar_url - ) - except discord.HTTPException as exc: - self.log.exception( - "Failed to send an attachment to the webhook", - exc_info=exc - ) - - self.message_history.message_count += 1 - - async def send_header(self, msg: Message) -> None: - """Sends a header embed with information about the relayed messages to the watch channel.""" - user_id = msg.author.id - - guild = self.bot.get_guild(GuildConfig.id) - actor = guild.get_member(self.watched_users[user_id]['actor']) - actor = actor.display_name if actor else self.watched_users[user_id]['actor'] - - inserted_at = self.watched_users[user_id]['inserted_at'] - time_delta = self._get_time_delta(inserted_at) - - reason = self.watched_users[user_id]['reason'] - - if isinstance(msg.channel, DMChannel): - # If a watched user DMs the bot there won't be a channel name or jump URL - # This could technically include a GroupChannel but bot's can't be in those - message_jump = "via DM" - else: - message_jump = f"in [#{msg.channel.name}]({msg.jump_url})" - - footer = f"Added {time_delta} by {actor} | Reason: {reason}" - embed = Embed(description=f"{msg.author.mention} {message_jump}") - embed.set_footer(text=textwrap.shorten(footer, width=128, placeholder="...")) - - await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.avatar_url) - - async def list_watched_users( - self, ctx: Context, oldest_first: bool = False, update_cache: bool = True - ) -> None: - """ - Gives an overview of the watched user list for this channel. - - The optional kwarg `oldest_first` orders the list by oldest entry. - - The optional kwarg `update_cache` specifies whether the cache should - be refreshed by polling the API. - """ - if update_cache: - if not await self.fetch_user_cache(): - await ctx.send(f":x: Failed to update {self.__class__.__name__} user cache, serving from cache") - update_cache = False - - lines = [] - for user_id, user_data in self.watched_users.items(): - inserted_at = user_data['inserted_at'] - time_delta = self._get_time_delta(inserted_at) - lines.append(f"• <@{user_id}> (added {time_delta})") - - if oldest_first: - lines.reverse() - - lines = lines or ("There's nothing here yet.",) - - embed = Embed( - title=f"{self.__class__.__name__} watched users ({'updated' if update_cache else 'cached'})", - color=Color.blue() - ) - await LinePaginator.paginate(lines, ctx, embed, empty=False) - - @staticmethod - def _get_time_delta(time_string: str) -> str: - """Returns the time in human-readable time delta format.""" - date_time = dateutil.parser.isoparse(time_string).replace(tzinfo=None) - time_delta = time_since(date_time, precision="minutes", max_units=1) - - return time_delta - - def _remove_user(self, user_id: int) -> None: - """Removes a user from a watch channel.""" - self.watched_users.pop(user_id, None) - self.message_queue.pop(user_id, None) - self.consumption_queue.pop(user_id, None) - - def cog_unload(self) -> None: - """Takes care of unloading the cog and canceling the consumption task.""" - self.log.trace("Unloading the cog") - if self._consume_task and not self._consume_task.done(): - self._consume_task.cancel() - try: - self._consume_task.result() - except asyncio.CancelledError as e: - self.log.exception( - "The consume task was canceled. Messages may be lost.", - exc_info=e - ) diff --git a/bot/cogs/moderation/watchchannels/bigbrother.py b/bot/cogs/moderation/watchchannels/bigbrother.py deleted file mode 100644 index 7db34bcf2..000000000 --- a/bot/cogs/moderation/watchchannels/bigbrother.py +++ /dev/null @@ -1,170 +0,0 @@ -import logging -import textwrap -from collections import ChainMap - -from discord.ext.commands import Cog, Context, group - -from bot.bot import Bot -from bot.cogs.moderation.infraction._utils import post_infraction -from bot.constants import Channels, MODERATION_ROLES, Webhooks -from bot.converters import FetchedMember -from bot.decorators import with_role -from ._watchchannel import WatchChannel - -log = logging.getLogger(__name__) - - -class BigBrother(WatchChannel, Cog, name="Big Brother"): - """Monitors users by relaying their messages to a watch channel to assist with moderation.""" - - def __init__(self, bot: Bot) -> None: - super().__init__( - bot, - destination=Channels.big_brother_logs, - webhook_id=Webhooks.big_brother, - api_endpoint='bot/infractions', - api_default_params={'active': 'true', 'type': 'watch', 'ordering': '-inserted_at'}, - logger=log - ) - - @group(name='bigbrother', aliases=('bb',), invoke_without_command=True) - @with_role(*MODERATION_ROLES) - async def bigbrother_group(self, ctx: Context) -> None: - """Monitors users by relaying their messages to the Big Brother watch channel.""" - await ctx.send_help(ctx.command) - - @bigbrother_group.command(name='watched', aliases=('all', 'list')) - @with_role(*MODERATION_ROLES) - async def watched_command( - self, ctx: Context, oldest_first: bool = False, update_cache: bool = True - ) -> None: - """ - Shows the users that are currently being monitored by Big Brother. - - The optional kwarg `oldest_first` can be used to order the list by oldest watched. - - The optional kwarg `update_cache` can be used to update the user - cache using the API before listing the users. - """ - await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) - - @bigbrother_group.command(name='oldest') - @with_role(*MODERATION_ROLES) - async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: - """ - Shows Big Brother monitored users ordered by oldest watched. - - The optional kwarg `update_cache` can be used to update the user - cache using the API before listing the users. - """ - await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) - - @bigbrother_group.command(name='watch', aliases=('w',)) - @with_role(*MODERATION_ROLES) - async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """ - Relay messages sent by the given `user` to the `#big-brother` channel. - - A `reason` for adding the user to Big Brother is required and will be displayed - in the header when relaying messages of this user to the watchchannel. - """ - await self.apply_watch(ctx, user, reason) - - @bigbrother_group.command(name='unwatch', aliases=('uw',)) - @with_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """Stop relaying messages by the given `user`.""" - await self.apply_unwatch(ctx, user, reason) - - async def apply_watch(self, ctx: Context, user: FetchedMember, reason: str) -> None: - """ - Add `user` to watched users and apply a watch infraction with `reason`. - - A message indicating the result of the operation is sent to `ctx`. - The message will include `user`'s previous watch infraction history, if it exists. - """ - if user.bot: - await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") - return - - if not await self.fetch_user_cache(): - await ctx.send(f":x: Updating the user cache failed, can't watch user {user}") - return - - if user.id in self.watched_users: - await ctx.send(f":x: {user} is already being watched.") - return - - response = await post_infraction(ctx, user, 'watch', reason, hidden=True, active=True) - - if response is not None: - self.watched_users[user.id] = response - msg = f":white_check_mark: Messages sent by {user} will now be relayed to Big Brother." - - history = await self.bot.api_client.get( - self.api_endpoint, - params={ - "user__id": str(user.id), - "active": "false", - 'type': 'watch', - 'ordering': '-inserted_at' - } - ) - - if len(history) > 1: - total = f"({len(history) // 2} previous infractions in total)" - end_reason = textwrap.shorten(history[0]["reason"], width=500, placeholder="...") - start_reason = f"Watched: {textwrap.shorten(history[1]['reason'], width=500, placeholder='...')}" - msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}\n\n{end_reason}```" - else: - msg = ":x: Failed to post the infraction: response was empty." - - await ctx.send(msg) - - async def apply_unwatch(self, ctx: Context, user: FetchedMember, reason: str, send_message: bool = True) -> None: - """ - Remove `user` from watched users and mark their infraction as inactive with `reason`. - - If `send_message` is True, a message indicating the result of the operation is sent to - `ctx`. - """ - active_watches = await self.bot.api_client.get( - self.api_endpoint, - params=ChainMap( - self.api_default_params, - {"user__id": str(user.id)} - ) - ) - if active_watches: - log.trace("Active watches for user found. Attempting to remove.") - [infraction] = active_watches - - await self.bot.api_client.patch( - f"{self.api_endpoint}/{infraction['id']}", - json={'active': False} - ) - - await post_infraction(ctx, user, 'watch', f"Unwatched: {reason}", hidden=True, active=False) - - self._remove_user(user.id) - - if not send_message: # Prevents a message being sent to the channel if part of a permanent ban - log.debug(f"Perma-banned user {user} was unwatched.") - return - log.trace("User is not banned. Sending message to channel") - message = f":white_check_mark: Messages sent by {user} will no longer be relayed." - - else: - log.trace("No active watches found for user.") - if not send_message: # Prevents a message being sent to the channel if part of a permanent ban - log.debug(f"{user} was not on the watch list; no removal necessary.") - return - log.trace("User is not perma banned. Send the error message.") - message = ":x: The specified user is currently not being watched." - - await ctx.send(message) - - -def setup(bot: Bot) -> None: - """Load the BigBrother cog.""" - bot.add_cog(BigBrother(bot)) diff --git a/bot/cogs/moderation/watchchannels/talentpool.py b/bot/cogs/moderation/watchchannels/talentpool.py deleted file mode 100644 index 2972f56e1..000000000 --- a/bot/cogs/moderation/watchchannels/talentpool.py +++ /dev/null @@ -1,269 +0,0 @@ -import logging -import textwrap -from collections import ChainMap - -from discord import Color, Embed, Member -from discord.ext.commands import Cog, Context, group - -from bot.api import ResponseCodeError -from bot.bot import Bot -from bot.constants import Channels, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks -from bot.converters import FetchedMember -from bot.decorators import with_role -from bot.pagination import LinePaginator -from bot.utils import time -from ._watchchannel import WatchChannel - -log = logging.getLogger(__name__) - - -class TalentPool(WatchChannel, Cog, name="Talentpool"): - """Relays messages of helper candidates to a watch channel to observe them.""" - - def __init__(self, bot: Bot) -> None: - super().__init__( - bot, - destination=Channels.talent_pool, - webhook_id=Webhooks.talent_pool, - api_endpoint='bot/nominations', - api_default_params={'active': 'true', 'ordering': '-inserted_at'}, - logger=log, - ) - - @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True) - @with_role(*MODERATION_ROLES) - async def nomination_group(self, ctx: Context) -> None: - """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" - await ctx.send_help(ctx.command) - - @nomination_group.command(name='watched', aliases=('all', 'list')) - @with_role(*MODERATION_ROLES) - async def watched_command( - self, ctx: Context, oldest_first: bool = False, update_cache: bool = True - ) -> None: - """ - Shows the users that are currently being monitored in the talent pool. - - The optional kwarg `oldest_first` can be used to order the list by oldest nomination. - - The optional kwarg `update_cache` can be used to update the user - cache using the API before listing the users. - """ - await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) - - @nomination_group.command(name='oldest') - @with_role(*MODERATION_ROLES) - async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: - """ - Shows talent pool monitored users ordered by oldest nomination. - - The optional kwarg `update_cache` can be used to update the user - cache using the API before listing the users. - """ - await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) - - @nomination_group.command(name='watch', aliases=('w', 'add', 'a')) - @with_role(*STAFF_ROLES) - async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """ - Relay messages sent by the given `user` to the `#talent-pool` channel. - - A `reason` for adding the user to the talent pool is required and will be displayed - in the header when relaying messages of this user to the channel. - """ - if user.bot: - await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") - return - - if isinstance(user, Member) and any(role.id in STAFF_ROLES for role in user.roles): - await ctx.send(":x: Nominating staff members, eh? Here's a cookie :cookie:") - return - - if not await self.fetch_user_cache(): - await ctx.send(f":x: Failed to update the user cache; can't add {user}") - return - - if user.id in self.watched_users: - await ctx.send(f":x: {user} is already being watched in the talent pool") - return - - # Manual request with `raise_for_status` as False because we want the actual response - session = self.bot.api_client.session - url = self.bot.api_client._url_for(self.api_endpoint) - kwargs = { - 'json': { - 'actor': ctx.author.id, - 'reason': reason, - 'user': user.id - }, - 'raise_for_status': False, - } - async with session.post(url, **kwargs) as resp: - response_data = await resp.json() - - if resp.status == 400 and response_data.get('user', False): - await ctx.send(":x: The specified user can't be found in the database tables") - return - else: - resp.raise_for_status() - - self.watched_users[user.id] = response_data - msg = f":white_check_mark: Messages sent by {user} will now be relayed to the talent pool channel" - - history = await self.bot.api_client.get( - self.api_endpoint, - params={ - "user__id": str(user.id), - "active": "false", - "ordering": "-inserted_at" - } - ) - - if history: - total = f"({len(history)} previous nominations in total)" - start_reason = f"Watched: {textwrap.shorten(history[0]['reason'], width=500, placeholder='...')}" - end_reason = f"Unwatched: {textwrap.shorten(history[0]['end_reason'], width=500, placeholder='...')}" - msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}\n\n{end_reason}```" - - await ctx.send(msg) - - @nomination_group.command(name='history', aliases=('info', 'search')) - @with_role(*MODERATION_ROLES) - async def history_command(self, ctx: Context, user: FetchedMember) -> None: - """Shows the specified user's nomination history.""" - result = await self.bot.api_client.get( - self.api_endpoint, - params={ - 'user__id': str(user.id), - 'ordering': "-active,-inserted_at" - } - ) - if not result: - await ctx.send(":warning: This user has never been nominated") - return - - embed = Embed( - title=f"Nominations for {user.display_name} `({user.id})`", - color=Color.blue() - ) - lines = [self._nomination_to_string(nomination) for nomination in result] - await LinePaginator.paginate( - lines, - ctx=ctx, - embed=embed, - empty=True, - max_lines=3, - max_size=1000 - ) - - @nomination_group.command(name='unwatch', aliases=('end', )) - @with_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """ - Ends the active nomination of the specified user with the given reason. - - Providing a `reason` is required. - """ - active_nomination = await self.bot.api_client.get( - self.api_endpoint, - params=ChainMap( - self.api_default_params, - {"user__id": str(user.id)} - ) - ) - - if not active_nomination: - await ctx.send(":x: The specified user does not have an active nomination") - return - - [nomination] = active_nomination - await self.bot.api_client.patch( - f"{self.api_endpoint}/{nomination['id']}", - json={'end_reason': reason, 'active': False} - ) - await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed") - self._remove_user(user.id) - - @nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True) - @with_role(*MODERATION_ROLES) - async def nomination_edit_group(self, ctx: Context) -> None: - """Commands to edit nominations.""" - await ctx.send_help(ctx.command) - - @nomination_edit_group.command(name='reason') - @with_role(*MODERATION_ROLES) - async def edit_reason_command(self, ctx: Context, nomination_id: int, *, reason: str) -> None: - """ - Edits the reason/unnominate reason for the nomination with the given `id` depending on the status. - - If the nomination is active, the reason for nominating the user will be edited; - If the nomination is no longer active, the reason for ending the nomination will be edited instead. - """ - try: - nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}") - except ResponseCodeError as e: - if e.response.status == 404: - self.log.trace(f"Nomination API 404: Can't nomination with id {nomination_id}") - await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`") - return - else: - raise - - field = "reason" if nomination["active"] else "end_reason" - - self.log.trace(f"Changing {field} for nomination with id {nomination_id} to {reason}") - - await self.bot.api_client.patch( - f"{self.api_endpoint}/{nomination_id}", - json={field: reason} - ) - - await ctx.send(f":white_check_mark: Updated the {field} of the nomination!") - - def _nomination_to_string(self, nomination_object: dict) -> str: - """Creates a string representation of a nomination.""" - guild = self.bot.get_guild(Guild.id) - - actor_id = nomination_object["actor"] - actor = guild.get_member(actor_id) - - active = nomination_object["active"] - log.debug(active) - log.debug(type(nomination_object["inserted_at"])) - - start_date = time.format_infraction(nomination_object["inserted_at"]) - if active: - lines = textwrap.dedent( - f""" - =============== - Status: **Active** - Date: {start_date} - Actor: {actor.mention if actor else actor_id} - Reason: {nomination_object["reason"]} - Nomination ID: `{nomination_object["id"]}` - =============== - """ - ) - else: - end_date = time.format_infraction(nomination_object["ended_at"]) - lines = textwrap.dedent( - f""" - =============== - Status: Inactive - Date: {start_date} - Actor: {actor.mention if actor else actor_id} - Reason: {nomination_object["reason"]} - - End date: {end_date} - Unwatch reason: {nomination_object["end_reason"]} - Nomination ID: `{nomination_object["id"]}` - =============== - """ - ) - - return lines.strip() - - -def setup(bot: Bot) -> None: - """Load the TalentPool cog.""" - bot.add_cog(TalentPool(bot)) diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py deleted file mode 100644 index ce95450e0..000000000 --- a/bot/cogs/off_topic_names.py +++ /dev/null @@ -1,162 +0,0 @@ -import asyncio -import difflib -import logging -from datetime import datetime, timedelta - -from discord import Colour, Embed -from discord.ext.commands import Cog, Context, group - -from bot.api import ResponseCodeError -from bot.bot import Bot -from bot.constants import Channels, MODERATION_ROLES -from bot.converters import OffTopicName -from bot.decorators import with_role -from bot.pagination import LinePaginator - -CHANNELS = (Channels.off_topic_0, Channels.off_topic_1, Channels.off_topic_2) -log = logging.getLogger(__name__) - - -async def update_names(bot: Bot) -> None: - """Background updater task that performs the daily channel name update.""" - while True: - # Since we truncate the compute timedelta to seconds, we add one second to ensure - # we go past midnight in the `seconds_to_sleep` set below. - today_at_midnight = datetime.utcnow().replace(microsecond=0, second=0, minute=0, hour=0) - next_midnight = today_at_midnight + timedelta(days=1) - seconds_to_sleep = (next_midnight - datetime.utcnow()).seconds + 1 - await asyncio.sleep(seconds_to_sleep) - - try: - channel_0_name, channel_1_name, channel_2_name = await bot.api_client.get( - 'bot/off-topic-channel-names', params={'random_items': 3} - ) - except ResponseCodeError as e: - log.error(f"Failed to get new off topic channel names: code {e.response.status}") - continue - channel_0, channel_1, channel_2 = (bot.get_channel(channel_id) for channel_id in CHANNELS) - - await channel_0.edit(name=f'ot0-{channel_0_name}') - await channel_1.edit(name=f'ot1-{channel_1_name}') - await channel_2.edit(name=f'ot2-{channel_2_name}') - log.debug( - "Updated off-topic channel names to" - f" {channel_0_name}, {channel_1_name} and {channel_2_name}" - ) - - -class OffTopicNames(Cog): - """Commands related to managing the off-topic category channel names.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.updater_task = None - - self.bot.loop.create_task(self.init_offtopic_updater()) - - def cog_unload(self) -> None: - """Cancel any running updater tasks on cog unload.""" - if self.updater_task is not None: - self.updater_task.cancel() - - async def init_offtopic_updater(self) -> None: - """Start off-topic channel updating event loop if it hasn't already started.""" - await self.bot.wait_until_guild_available() - if self.updater_task is None: - coro = update_names(self.bot) - self.updater_task = self.bot.loop.create_task(coro) - - @group(name='otname', aliases=('otnames', 'otn'), invoke_without_command=True) - @with_role(*MODERATION_ROLES) - async def otname_group(self, ctx: Context) -> None: - """Add or list items from the off-topic channel name rotation.""" - await ctx.send_help(ctx.command) - - @otname_group.command(name='add', aliases=('a',)) - @with_role(*MODERATION_ROLES) - async def add_command(self, ctx: Context, *, name: OffTopicName) -> None: - """ - Adds a new off-topic name to the rotation. - - The name is not added if it is too similar to an existing name. - """ - existing_names = await self.bot.api_client.get('bot/off-topic-channel-names') - close_match = difflib.get_close_matches(name, existing_names, n=1, cutoff=0.8) - - if close_match: - match = close_match[0] - log.info( - f"{ctx.author} tried to add channel name '{name}' but it was too similar to '{match}'" - ) - await ctx.send( - f":x: The channel name `{name}` is too similar to `{match}`, and thus was not added. " - "Use `!otn forceadd` to override this check." - ) - else: - await self._add_name(ctx, name) - - @otname_group.command(name='forceadd', aliases=('fa',)) - @with_role(*MODERATION_ROLES) - async def force_add_command(self, ctx: Context, *, name: OffTopicName) -> None: - """Forcefully adds a new off-topic name to the rotation.""" - await self._add_name(ctx, name) - - async def _add_name(self, ctx: Context, name: str) -> None: - """Adds an off-topic channel name to the site storage.""" - await self.bot.api_client.post('bot/off-topic-channel-names', params={'name': name}) - - log.info(f"{ctx.author} added the off-topic channel name '{name}'") - await ctx.send(f":ok_hand: Added `{name}` to the names list.") - - @otname_group.command(name='delete', aliases=('remove', 'rm', 'del', 'd')) - @with_role(*MODERATION_ROLES) - async def delete_command(self, ctx: Context, *, name: OffTopicName) -> None: - """Removes a off-topic name from the rotation.""" - await self.bot.api_client.delete(f'bot/off-topic-channel-names/{name}') - - log.info(f"{ctx.author} deleted the off-topic channel name '{name}'") - await ctx.send(f":ok_hand: Removed `{name}` from the names list.") - - @otname_group.command(name='list', aliases=('l',)) - @with_role(*MODERATION_ROLES) - async def list_command(self, ctx: Context) -> None: - """ - Lists all currently known off-topic channel names in a paginator. - - Restricted to Moderator and above to not spoil the surprise. - """ - result = await self.bot.api_client.get('bot/off-topic-channel-names') - lines = sorted(f"• {name}" for name in result) - embed = Embed( - title=f"Known off-topic names (`{len(result)}` total)", - colour=Colour.blue() - ) - if result: - await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False) - else: - embed.description = "Hmmm, seems like there's nothing here yet." - await ctx.send(embed=embed) - - @otname_group.command(name='search', aliases=('s',)) - @with_role(*MODERATION_ROLES) - async def search_command(self, ctx: Context, *, query: OffTopicName) -> None: - """Search for an off-topic name.""" - result = await self.bot.api_client.get('bot/off-topic-channel-names') - in_matches = {name for name in result if query in name} - close_matches = difflib.get_close_matches(query, result, n=10, cutoff=0.70) - lines = sorted(f"• {name}" for name in in_matches.union(close_matches)) - embed = Embed( - title="Query results", - colour=Colour.blue() - ) - - if lines: - await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False) - else: - embed.description = "Nothing found." - await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the OffTopicNames cog.""" - bot.add_cog(OffTopicNames(bot)) diff --git a/bot/cogs/utils/__init__.py b/bot/cogs/utils/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/bot/cogs/utils/bot.py b/bot/cogs/utils/bot.py deleted file mode 100644 index 71ed54f60..000000000 --- a/bot/cogs/utils/bot.py +++ /dev/null @@ -1,385 +0,0 @@ -import ast -import logging -import re -import time -from typing import Optional, Tuple - -from discord import Embed, Message, RawMessageUpdateEvent, TextChannel -from discord.ext.commands import Cog, Context, command, group - -from bot.bot import Bot -from bot.cogs.filters.token_remover import TokenRemover -from bot.constants import Categories, Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs -from bot.decorators import with_role -from bot.utils.messages import wait_for_deletion - -log = logging.getLogger(__name__) - -RE_MARKDOWN = re.compile(r'([*_~`|>])') - - -class BotCog(Cog, name="Bot"): - """Bot information commands.""" - - def __init__(self, bot: Bot): - self.bot = bot - - # Stores allowed channels plus epoch time since last call. - self.channel_cooldowns = { - Channels.python_discussion: 0, - } - - # These channels will also work, but will not be subject to cooldown - self.channel_whitelist = ( - Channels.bot_commands, - ) - - # Stores improperly formatted Python codeblock message ids and the corresponding bot message - self.codeblock_message_ids = {} - - @group(invoke_without_command=True, name="bot", hidden=True) - @with_role(Roles.verified) - async def botinfo_group(self, ctx: Context) -> None: - """Bot informational commands.""" - await ctx.send_help(ctx.command) - - @botinfo_group.command(name='about', aliases=('info',), hidden=True) - @with_role(Roles.verified) - async def about_command(self, ctx: Context) -> None: - """Get information about the bot.""" - embed = Embed( - description="A utility bot designed just for the Python server! Try `!help` for more info.", - url="https://github.com/python-discord/bot" - ) - - embed.add_field(name="Total Users", value=str(len(self.bot.get_guild(Guild.id).members))) - embed.set_author( - name="Python Bot", - url="https://github.com/python-discord/bot", - icon_url=URLs.bot_avatar - ) - - await ctx.send(embed=embed) - - @command(name='echo', aliases=('print',)) - @with_role(*MODERATION_ROLES) - async def echo_command(self, ctx: Context, channel: Optional[TextChannel], *, text: str) -> None: - """Repeat the given message in either a specified channel or the current channel.""" - if channel is None: - await ctx.send(text) - else: - await channel.send(text) - - @command(name='embed') - @with_role(*MODERATION_ROLES) - async def embed_command(self, ctx: Context, channel: Optional[TextChannel], *, text: str) -> None: - """Send the input within an embed to either a specified channel or the current channel.""" - embed = Embed(description=text) - - if channel is None: - await ctx.send(embed=embed) - else: - await channel.send(embed=embed) - - def codeblock_stripping(self, msg: str, bad_ticks: bool) -> Optional[Tuple[Tuple[str, ...], str]]: - """ - Strip msg in order to find Python code. - - Tries to strip out Python code out of msg and returns the stripped block or - None if the block is a valid Python codeblock. - """ - if msg.count("\n") >= 3: - # Filtering valid Python codeblocks and exiting if a valid Python codeblock is found. - if re.search("```(?:py|python)\n(.*?)```", msg, re.IGNORECASE | re.DOTALL) and not bad_ticks: - log.trace( - "Someone wrote a message that was already a " - "valid Python syntax highlighted code block. No action taken." - ) - return None - - else: - # Stripping backticks from every line of the message. - log.trace(f"Stripping backticks from message.\n\n{msg}\n\n") - content = "" - for line in msg.splitlines(keepends=True): - content += line.strip("`") - - content = content.strip() - - # Remove "Python" or "Py" from start of the message if it exists. - log.trace(f"Removing 'py' or 'python' from message.\n\n{content}\n\n") - pycode = False - if content.lower().startswith("python"): - content = content[6:] - pycode = True - elif content.lower().startswith("py"): - content = content[2:] - pycode = True - - if pycode: - content = content.splitlines(keepends=True) - - # Check if there might be code in the first line, and preserve it. - first_line = content[0] - if " " in content[0]: - first_space = first_line.index(" ") - content[0] = first_line[first_space:] - content = "".join(content) - - # If there's no code we can just get rid of the first line. - else: - content = "".join(content[1:]) - - # Strip it again to remove any leading whitespace. This is neccessary - # if the first line of the message looked like ```python - old = content.strip() - - # Strips REPL code out of the message if there is any. - content, repl_code = self.repl_stripping(old) - if old != content: - return (content, old), repl_code - - # Try to apply indentation fixes to the code. - content = self.fix_indentation(content) - - # Check if the code contains backticks, if it does ignore the message. - if "`" in content: - log.trace("Detected ` inside the code, won't reply") - return None - else: - log.trace(f"Returning message.\n\n{content}\n\n") - return (content,), repl_code - - def fix_indentation(self, msg: str) -> str: - """Attempts to fix badly indented code.""" - def unindent(code: str, skip_spaces: int = 0) -> str: - """Unindents all code down to the number of spaces given in skip_spaces.""" - final = "" - current = code[0] - leading_spaces = 0 - - # Get numbers of spaces before code in the first line. - while current == " ": - current = code[leading_spaces + 1] - leading_spaces += 1 - leading_spaces -= skip_spaces - - # If there are any, remove that number of spaces from every line. - if leading_spaces > 0: - for line in code.splitlines(keepends=True): - line = line[leading_spaces:] - final += line - return final - else: - return code - - # Apply fix for "all lines are overindented" case. - msg = unindent(msg) - - # If the first line does not end with a colon, we can be - # certain the next line will be on the same indentation level. - # - # If it does end with a colon, we will need to indent all successive - # lines one additional level. - first_line = msg.splitlines()[0] - code = "".join(msg.splitlines(keepends=True)[1:]) - if not first_line.endswith(":"): - msg = f"{first_line}\n{unindent(code)}" - else: - msg = f"{first_line}\n{unindent(code, 4)}" - return msg - - def repl_stripping(self, msg: str) -> Tuple[str, bool]: - """ - Strip msg in order to extract Python code out of REPL output. - - Tries to strip out REPL Python code out of msg and returns the stripped msg. - - Returns True for the boolean if REPL code was found in the input msg. - """ - final = "" - for line in msg.splitlines(keepends=True): - if line.startswith(">>>") or line.startswith("..."): - final += line[4:] - log.trace(f"Formatted: \n\n{msg}\n\n to \n\n{final}\n\n") - if not final: - log.trace(f"Found no REPL code in \n\n{msg}\n\n") - return msg, False - else: - log.trace(f"Found REPL code in \n\n{msg}\n\n") - return final.rstrip(), True - - def has_bad_ticks(self, msg: Message) -> bool: - """Check to see if msg contains ticks that aren't '`'.""" - not_backticks = [ - "'''", '"""', "\u00b4\u00b4\u00b4", "\u2018\u2018\u2018", "\u2019\u2019\u2019", - "\u2032\u2032\u2032", "\u201c\u201c\u201c", "\u201d\u201d\u201d", "\u2033\u2033\u2033", - "\u3003\u3003\u3003" - ] - - return msg.content[:3] in not_backticks - - @Cog.listener() - async def on_message(self, msg: Message) -> None: - """ - Detect poorly formatted Python code in new messages. - - If poorly formatted code is detected, send the user a helpful message explaining how to do - properly formatted Python syntax highlighting codeblocks. - """ - is_help_channel = ( - getattr(msg.channel, "category", None) - and msg.channel.category.id in (Categories.help_available, Categories.help_in_use) - ) - parse_codeblock = ( - ( - is_help_channel - or msg.channel.id in self.channel_cooldowns - or msg.channel.id in self.channel_whitelist - ) - and not msg.author.bot - and len(msg.content.splitlines()) > 3 - and not TokenRemover.find_token_in_message(msg) - ) - - if parse_codeblock: # no token in the msg - on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300 - if not on_cooldown or DEBUG_MODE: - try: - if self.has_bad_ticks(msg): - ticks = msg.content[:3] - content = self.codeblock_stripping(f"```{msg.content[3:-3]}```", True) - if content is None: - return - - content, repl_code = content - - if len(content) == 2: - content = content[1] - else: - content = content[0] - - space_left = 204 - if len(content) >= space_left: - current_length = 0 - lines_walked = 0 - for line in content.splitlines(keepends=True): - if current_length + len(line) > space_left or lines_walked == 10: - break - current_length += len(line) - lines_walked += 1 - content = content[:current_length] + "#..." - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) - howto = ( - "It looks like you are trying to paste code into this channel.\n\n" - "You seem to be using the wrong symbols to indicate where the codeblock should start. " - f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n" - "**Here is an example of how it should look:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) - - else: - howto = "" - content = self.codeblock_stripping(msg.content, False) - if content is None: - return - - content, repl_code = content - # Attempts to parse the message into an AST node. - # Invalid Python code will raise a SyntaxError. - tree = ast.parse(content[0]) - - # Multiple lines of single words could be interpreted as expressions. - # This check is to avoid all nodes being parsed as expressions. - # (e.g. words over multiple lines) - if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: - # Shorten the code to 10 lines and/or 204 characters. - space_left = 204 - if content and repl_code: - content = content[1] - else: - content = content[0] - - if len(content) >= space_left: - current_length = 0 - lines_walked = 0 - for line in content.splitlines(keepends=True): - if current_length + len(line) > space_left or lines_walked == 10: - break - current_length += len(line) - lines_walked += 1 - content = content[:current_length] + "#..." - - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) - howto += ( - "It looks like you're trying to paste code into this channel.\n\n" - "Discord has support for Markdown, which allows you to post code with full " - "syntax highlighting. Please use these whenever you paste code, as this " - "helps improve the legibility and makes it easier for us to help you.\n\n" - f"**To do this, use the following method:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) - - log.debug(f"{msg.author} posted something that needed to be put inside python code " - "blocks. Sending the user some instructions.") - else: - log.trace("The code consists only of expressions, not sending instructions") - - if howto != "": - # Increase amount of codeblock correction in stats - self.bot.stats.incr("codeblock_corrections") - howto_embed = Embed(description=howto) - bot_message = await msg.channel.send(f"Hey {msg.author.mention}!", embed=howto_embed) - self.codeblock_message_ids[msg.id] = bot_message.id - - self.bot.loop.create_task( - wait_for_deletion(bot_message, user_ids=(msg.author.id,), client=self.bot) - ) - else: - return - - if msg.channel.id not in self.channel_whitelist: - self.channel_cooldowns[msg.channel.id] = time.time() - - except SyntaxError: - log.trace( - f"{msg.author} posted in a help channel, and when we tried to parse it as Python code, " - "ast.parse raised a SyntaxError. This probably just means it wasn't Python code. " - f"The message that was posted was:\n\n{msg.content}\n\n" - ) - - @Cog.listener() - async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: - """Check to see if an edited message (previously called out) still contains poorly formatted code.""" - if ( - # Checks to see if the message was called out by the bot - payload.message_id not in self.codeblock_message_ids - # Makes sure that there is content in the message - or payload.data.get("content") is None - # Makes sure there's a channel id in the message payload - or payload.data.get("channel_id") is None - ): - return - - # Retrieve channel and message objects for use later - channel = self.bot.get_channel(int(payload.data.get("channel_id"))) - user_message = await channel.fetch_message(payload.message_id) - - # Checks to see if the user has corrected their codeblock. If it's fixed, has_fixed_codeblock will be None - has_fixed_codeblock = self.codeblock_stripping(payload.data.get("content"), self.has_bad_ticks(user_message)) - - # If the message is fixed, delete the bot message and the entry from the id dictionary - if has_fixed_codeblock is None: - bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) - await bot_message.delete() - del self.codeblock_message_ids[payload.message_id] - log.trace("User's incorrect code block has been fixed. Removing bot formatting message.") - - -def setup(bot: Bot) -> None: - """Load the Bot cog.""" - bot.add_cog(BotCog(bot)) diff --git a/bot/cogs/utils/clean.py b/bot/cogs/utils/clean.py deleted file mode 100644 index c156ff02e..000000000 --- a/bot/cogs/utils/clean.py +++ /dev/null @@ -1,272 +0,0 @@ -import logging -import random -import re -from typing import Iterable, Optional - -from discord import Colour, Embed, Message, TextChannel, User -from discord.ext import commands -from discord.ext.commands import Cog, Context, group - -from bot.bot import Bot -from bot.cogs.moderation.modlog import ModLog -from bot.constants import ( - Channels, CleanMessages, Colours, Event, Icons, MODERATION_ROLES, NEGATIVE_REPLIES -) -from bot.decorators import with_role - -log = logging.getLogger(__name__) - - -class Clean(Cog): - """ - A cog that allows messages to be deleted in bulk, while applying various filters. - - You can delete messages sent by a specific user, messages sent by bots, all messages, or messages that match a - specific regular expression. - - The deleted messages are saved and uploaded to the database via an API endpoint, and a URL is returned which can be - used to view the messages in the Discord dark theme style. - """ - - def __init__(self, bot: Bot): - self.bot = bot - self.cleaning = False - - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - async def _clean_messages( - self, - amount: int, - ctx: Context, - channels: Iterable[TextChannel], - bots_only: bool = False, - user: User = None, - regex: Optional[str] = None, - until_message: Optional[Message] = None, - ) -> None: - """A helper function that does the actual message cleaning.""" - def predicate_bots_only(message: Message) -> bool: - """Return True if the message was sent by a bot.""" - return message.author.bot - - def predicate_specific_user(message: Message) -> bool: - """Return True if the message was sent by the user provided in the _clean_messages call.""" - return message.author == user - - def predicate_regex(message: Message) -> bool: - """Check if the regex provided in _clean_messages matches the message content or any embed attributes.""" - content = [message.content] - - # Add the content for all embed attributes - for embed in message.embeds: - content.append(embed.title) - content.append(embed.description) - content.append(embed.footer.text) - content.append(embed.author.name) - for field in embed.fields: - content.append(field.name) - content.append(field.value) - - # Get rid of empty attributes and turn it into a string - content = [attr for attr in content if attr] - content = "\n".join(content) - - # Now let's see if there's a regex match - if not content: - return False - else: - return bool(re.search(regex.lower(), content.lower())) - - # Is this an acceptable amount of messages to clean? - if amount > CleanMessages.message_limit: - embed = Embed( - color=Colour(Colours.soft_red), - title=random.choice(NEGATIVE_REPLIES), - description=f"You cannot clean more than {CleanMessages.message_limit} messages." - ) - await ctx.send(embed=embed) - return - - # Are we already performing a clean? - if self.cleaning: - embed = Embed( - color=Colour(Colours.soft_red), - title=random.choice(NEGATIVE_REPLIES), - description="Please wait for the currently ongoing clean operation to complete." - ) - await ctx.send(embed=embed) - return - - # Set up the correct predicate - if bots_only: - predicate = predicate_bots_only # Delete messages from bots - elif user: - predicate = predicate_specific_user # Delete messages from specific user - elif regex: - predicate = predicate_regex # Delete messages that match regex - else: - predicate = None # Delete all messages - - # Default to using the invoking context's channel - if not channels: - channels = [ctx.channel] - - # Delete the invocation first - self.mod_log.ignore(Event.message_delete, ctx.message.id) - await ctx.message.delete() - - messages = [] - message_ids = [] - self.cleaning = True - - # Find the IDs of the messages to delete. IDs are needed in order to ignore mod log events. - for channel in channels: - async for message in channel.history(limit=amount): - - # If at any point the cancel command is invoked, we should stop. - if not self.cleaning: - return - - # If we are looking for specific message. - if until_message: - - # we could use ID's here however in case if the message we are looking for gets deleted, - # we won't have a way to figure that out thus checking for datetime should be more reliable - if message.created_at < until_message.created_at: - # means we have found the message until which we were supposed to be deleting. - break - - # Since we will be using `delete_messages` method of a TextChannel and we need message objects to - # use it as well as to send logs we will start appending messages here instead adding them from - # purge. - messages.append(message) - - # If the message passes predicate, let's save it. - if predicate is None or predicate(message): - message_ids.append(message.id) - - self.cleaning = False - - # Now let's delete the actual messages with purge. - self.mod_log.ignore(Event.message_delete, *message_ids) - for channel in channels: - if until_message: - for i in range(0, len(messages), 100): - # while purge automatically handles the amount of messages - # delete_messages only allows for up to 100 messages at once - # thus we need to paginate the amount to always be <= 100 - await channel.delete_messages(messages[i:i + 100]) - else: - messages += await channel.purge(limit=amount, check=predicate) - - # Reverse the list to restore chronological order - if messages: - messages = reversed(messages) - log_url = await self.mod_log.upload_log(messages, ctx.author.id) - else: - # Can't build an embed, nothing to clean! - embed = Embed( - color=Colour(Colours.soft_red), - description="No matching messages could be found." - ) - await ctx.send(embed=embed, delete_after=10) - return - - # Build the embed and send it - target_channels = ", ".join(channel.mention for channel in channels) - - message = ( - f"**{len(message_ids)}** messages deleted in {target_channels} by **{ctx.author.name}**\n\n" - f"A log of the deleted messages can be found [here]({log_url})." - ) - - await self.mod_log.send_log_message( - icon_url=Icons.message_bulk_delete, - colour=Colour(Colours.soft_red), - title="Bulk message delete", - text=message, - channel_id=Channels.mod_log, - ) - - @group(invoke_without_command=True, name="clean", aliases=["purge"]) - @with_role(*MODERATION_ROLES) - async def clean_group(self, ctx: Context) -> None: - """Commands for cleaning messages in channels.""" - await ctx.send_help(ctx.command) - - @clean_group.command(name="user", aliases=["users"]) - @with_role(*MODERATION_ROLES) - async def clean_user( - self, - ctx: Context, - user: User, - amount: Optional[int] = 10, - channels: commands.Greedy[TextChannel] = None - ) -> None: - """Delete messages posted by the provided user, stop cleaning after traversing `amount` messages.""" - await self._clean_messages(amount, ctx, user=user, channels=channels) - - @clean_group.command(name="all", aliases=["everything"]) - @with_role(*MODERATION_ROLES) - async def clean_all( - self, - ctx: Context, - amount: Optional[int] = 10, - channels: commands.Greedy[TextChannel] = None - ) -> None: - """Delete all messages, regardless of poster, stop cleaning after traversing `amount` messages.""" - await self._clean_messages(amount, ctx, channels=channels) - - @clean_group.command(name="bots", aliases=["bot"]) - @with_role(*MODERATION_ROLES) - async def clean_bots( - self, - ctx: Context, - amount: Optional[int] = 10, - channels: commands.Greedy[TextChannel] = None - ) -> None: - """Delete all messages posted by a bot, stop cleaning after traversing `amount` messages.""" - await self._clean_messages(amount, ctx, bots_only=True, channels=channels) - - @clean_group.command(name="regex", aliases=["word", "expression"]) - @with_role(*MODERATION_ROLES) - async def clean_regex( - self, - ctx: Context, - regex: str, - amount: Optional[int] = 10, - channels: commands.Greedy[TextChannel] = None - ) -> None: - """Delete all messages that match a certain regex, stop cleaning after traversing `amount` messages.""" - await self._clean_messages(amount, ctx, regex=regex, channels=channels) - - @clean_group.command(name="message", aliases=["messages"]) - @with_role(*MODERATION_ROLES) - async def clean_message(self, ctx: Context, message: Message) -> None: - """Delete all messages until certain message, stop cleaning after hitting the `message`.""" - await self._clean_messages( - CleanMessages.message_limit, - ctx, - channels=[message.channel], - until_message=message - ) - - @clean_group.command(name="stop", aliases=["cancel", "abort"]) - @with_role(*MODERATION_ROLES) - async def clean_cancel(self, ctx: Context) -> None: - """If there is an ongoing cleaning process, attempt to immediately cancel it.""" - self.cleaning = False - - embed = Embed( - color=Colour.blurple(), - description="Clean interrupted." - ) - await ctx.send(embed=embed, delete_after=10) - - -def setup(bot: Bot) -> None: - """Load the Clean cog.""" - bot.add_cog(Clean(bot)) diff --git a/bot/cogs/utils/eval.py b/bot/cogs/utils/eval.py deleted file mode 100644 index eb8bfb1cf..000000000 --- a/bot/cogs/utils/eval.py +++ /dev/null @@ -1,202 +0,0 @@ -import contextlib -import inspect -import logging -import pprint -import re -import textwrap -import traceback -from io import StringIO -from typing import Any, Optional, Tuple - -import discord -from discord.ext.commands import Cog, Context, group - -from bot.bot import Bot -from bot.constants import Roles -from bot.decorators import with_role -from bot.interpreter import Interpreter - -log = logging.getLogger(__name__) - - -class CodeEval(Cog): - """Owner and admin feature that evaluates code and returns the result to the channel.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.env = {} - self.ln = 0 - self.stdout = StringIO() - - self.interpreter = Interpreter(bot) - - def _format(self, inp: str, out: Any) -> Tuple[str, Optional[discord.Embed]]: - """Format the eval output into a string & attempt to format it into an Embed.""" - self._ = out - - res = "" - - # Erase temp input we made - if inp.startswith("_ = "): - inp = inp[4:] - - # Get all non-empty lines - lines = [line for line in inp.split("\n") if line.strip()] - if len(lines) != 1: - lines += [""] - - # Create the input dialog - for i, line in enumerate(lines): - if i == 0: - # Start dialog - start = f"In [{self.ln}]: " - - else: - # Indent the 3 dots correctly; - # Normally, it's something like - # In [X]: - # ...: - # - # But if it's - # In [XX]: - # ...: - # - # You can see it doesn't look right. - # This code simply indents the dots - # far enough to align them. - # we first `str()` the line number - # then we get the length - # and use `str.rjust()` - # to indent it. - start = "...: ".rjust(len(str(self.ln)) + 7) - - if i == len(lines) - 2: - if line.startswith("return"): - line = line[6:].strip() - - # Combine everything - res += (start + line + "\n") - - self.stdout.seek(0) - text = self.stdout.read() - self.stdout.close() - self.stdout = StringIO() - - if text: - res += (text + "\n") - - if out is None: - # No output, return the input statement - return (res, None) - - res += f"Out[{self.ln}]: " - - if isinstance(out, discord.Embed): - # We made an embed? Send that as embed - res += "" - res = (res, out) - - else: - if (isinstance(out, str) and out.startswith("Traceback (most recent call last):\n")): - # Leave out the traceback message - out = "\n" + "\n".join(out.split("\n")[1:]) - - if isinstance(out, str): - pretty = out - else: - pretty = pprint.pformat(out, compact=True, width=60) - - if pretty != str(out): - # We're using the pretty version, start on the next line - res += "\n" - - if pretty.count("\n") > 20: - # Text too long, shorten - li = pretty.split("\n") - - pretty = ("\n".join(li[:3]) # First 3 lines - + "\n ...\n" # Ellipsis to indicate removed lines - + "\n".join(li[-3:])) # last 3 lines - - # Add the output - res += pretty - res = (res, None) - - return res # Return (text, embed) - - async def _eval(self, ctx: Context, code: str) -> Optional[discord.Message]: - """Eval the input code string & send an embed to the invoking context.""" - self.ln += 1 - - if code.startswith("exit"): - self.ln = 0 - self.env = {} - return await ctx.send("```Reset history!```") - - env = { - "message": ctx.message, - "author": ctx.message.author, - "channel": ctx.channel, - "guild": ctx.guild, - "ctx": ctx, - "self": self, - "bot": self.bot, - "inspect": inspect, - "discord": discord, - "contextlib": contextlib - } - - self.env.update(env) - - # Ignore this code, it works - code_ = """ -async def func(): # (None,) -> Any - try: - with contextlib.redirect_stdout(self.stdout): -{0} - if '_' in locals(): - if inspect.isawaitable(_): - _ = await _ - return _ - finally: - self.env.update(locals()) -""".format(textwrap.indent(code, ' ')) - - try: - exec(code_, self.env) # noqa: B102,S102 - func = self.env['func'] - res = await func() - - except Exception: - res = traceback.format_exc() - - out, embed = self._format(code, res) - await ctx.send(f"```py\n{out}```", embed=embed) - - @group(name='internal', aliases=('int',)) - @with_role(Roles.owners, Roles.admins) - async def internal_group(self, ctx: Context) -> None: - """Internal commands. Top secret!""" - if not ctx.invoked_subcommand: - await ctx.send_help(ctx.command) - - @internal_group.command(name='eval', aliases=('e',)) - @with_role(Roles.admins, Roles.owners) - async def eval(self, ctx: Context, *, code: str) -> None: - """Run eval in a REPL-like format.""" - code = code.strip("`") - if re.match('py(thon)?\n', code): - code = "\n".join(code.split("\n")[1:]) - - if not re.search( # Check if it's an expression - r"^(return|import|for|while|def|class|" - r"from|exit|[a-zA-Z0-9]+\s*=)", code, re.M) and len( - code.split("\n")) == 1: - code = "_ = " + code - - await self._eval(ctx, code) - - -def setup(bot: Bot) -> None: - """Load the CodeEval cog.""" - bot.add_cog(CodeEval(bot)) diff --git a/bot/cogs/utils/extensions.py b/bot/cogs/utils/extensions.py deleted file mode 100644 index 2cde07035..000000000 --- a/bot/cogs/utils/extensions.py +++ /dev/null @@ -1,289 +0,0 @@ -import functools -import importlib -import inspect -import logging -import pkgutil -import typing as t -from enum import Enum - -from discord import Colour, Embed -from discord.ext import commands -from discord.ext.commands import Context, group - -from bot import cogs -from bot.bot import Bot -from bot.constants import Emojis, MODERATION_ROLES, Roles, URLs -from bot.pagination import LinePaginator -from bot.utils.checks import with_role_check - -log = logging.getLogger(__name__) - - -def walk_extensions() -> t.Iterator[str]: - """Yield extension names from the bot.cogs subpackage.""" - - def on_error(name: str) -> t.NoReturn: - raise ImportError(name=name) # pragma: no cover - - for module in pkgutil.walk_packages(cogs.__path__, f"{cogs.__name__}.", onerror=on_error): - if module.name.rsplit(".", maxsplit=1)[-1].startswith("_"): - # Ignore module/package names starting with an underscore. - continue - - if module.ispkg: - imported = importlib.import_module(module.name) - if not inspect.isfunction(getattr(imported, "setup", None)): - # If it lacks a setup function, it's not an extension. - continue - - yield module.name - - -UNLOAD_BLACKLIST = {f"{cogs.__name__}.utils.extensions", f"{cogs.__name__}.moderation.modlog"} -EXTENSIONS = frozenset(walk_extensions()) -COG_PATH_LEN = len(cogs.__name__.split(".")) - - -class Action(Enum): - """Represents an action to perform on an extension.""" - - # Need to be partial otherwise they are considered to be function definitions. - LOAD = functools.partial(Bot.load_extension) - UNLOAD = functools.partial(Bot.unload_extension) - RELOAD = functools.partial(Bot.reload_extension) - - -class Extension(commands.Converter): - """ - Fully qualify the name of an extension and ensure it exists. - - The * and ** values bypass this when used with the reload command. - """ - - async def convert(self, ctx: Context, argument: str) -> str: - """Fully qualify the name of an extension and ensure it exists.""" - # Special values to reload all extensions - if argument == "*" or argument == "**": - return argument - - argument = argument.lower() - - if argument in EXTENSIONS: - return argument - elif (qualified_arg := f"{cogs.__name__}.{argument}") in EXTENSIONS: - return qualified_arg - - matches = [] - for ext in EXTENSIONS: - name = ext.rsplit(".", maxsplit=1)[-1] - if argument == name: - matches.append(ext) - - if len(matches) > 1: - matches.sort() - names = "\n".join(matches) - raise commands.BadArgument( - f":x: `{argument}` is an ambiguous extension name. " - f"Please use one of the following fully-qualified names.```\n{names}```" - ) - elif matches: - return matches[0] - else: - raise commands.BadArgument(f":x: Could not find the extension `{argument}`.") - - -class Extensions(commands.Cog): - """Extension management commands.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @group(name="extensions", aliases=("ext", "exts", "c", "cogs"), invoke_without_command=True) - async def extensions_group(self, ctx: Context) -> None: - """Load, unload, reload, and list loaded extensions.""" - await ctx.send_help(ctx.command) - - @extensions_group.command(name="load", aliases=("l",)) - async def load_command(self, ctx: Context, *extensions: Extension) -> None: - r""" - Load extensions given their fully qualified or unqualified names. - - If '\*' or '\*\*' is given as the name, all unloaded extensions will be loaded. - """ # noqa: W605 - if not extensions: - await ctx.send_help(ctx.command) - return - - if "*" in extensions or "**" in extensions: - extensions = set(EXTENSIONS) - set(self.bot.extensions.keys()) - - msg = self.batch_manage(Action.LOAD, *extensions) - await ctx.send(msg) - - @extensions_group.command(name="unload", aliases=("ul",)) - async def unload_command(self, ctx: Context, *extensions: Extension) -> None: - r""" - Unload currently loaded extensions given their fully qualified or unqualified names. - - If '\*' or '\*\*' is given as the name, all loaded extensions will be unloaded. - """ # noqa: W605 - if not extensions: - await ctx.send_help(ctx.command) - return - - blacklisted = "\n".join(UNLOAD_BLACKLIST & set(extensions)) - - if blacklisted: - msg = f":x: The following extension(s) may not be unloaded:```{blacklisted}```" - else: - if "*" in extensions or "**" in extensions: - extensions = set(self.bot.extensions.keys()) - UNLOAD_BLACKLIST - - msg = self.batch_manage(Action.UNLOAD, *extensions) - - await ctx.send(msg) - - @extensions_group.command(name="reload", aliases=("r",)) - async def reload_command(self, ctx: Context, *extensions: Extension) -> None: - r""" - Reload extensions given their fully qualified or unqualified names. - - If an extension fails to be reloaded, it will be rolled-back to the prior working state. - - If '\*' is given as the name, all currently loaded extensions will be reloaded. - If '\*\*' is given as the name, all extensions, including unloaded ones, will be reloaded. - """ # noqa: W605 - if not extensions: - await ctx.send_help(ctx.command) - return - - if "**" in extensions: - extensions = EXTENSIONS - elif "*" in extensions: - extensions = set(self.bot.extensions.keys()) | set(extensions) - extensions.remove("*") - - msg = self.batch_manage(Action.RELOAD, *extensions) - - await ctx.send(msg) - - @extensions_group.command(name="list", aliases=("all",)) - async def list_command(self, ctx: Context) -> None: - """ - Get a list of all extensions, including their loaded status. - - Grey indicates that the extension is unloaded. - Green indicates that the extension is currently loaded. - """ - embed = Embed(colour=Colour.blurple()) - embed.set_author( - name="Extensions List", - url=URLs.github_bot_repo, - icon_url=URLs.bot_avatar - ) - - lines = [] - categories = self.group_extension_statuses() - for category, extensions in sorted(categories.items()): - # Treat each category as a single line by concatenating everything. - # This ensures the paginator will not cut off a page in the middle of a category. - category = category.replace("_", " ").title() - extensions = "\n".join(sorted(extensions)) - lines.append(f"**{category}**\n{extensions}\n") - - log.debug(f"{ctx.author} requested a list of all cogs. Returning a paginated list.") - await LinePaginator.paginate(lines, ctx, embed, scale_to_size=700, empty=False) - - def group_extension_statuses(self) -> t.Mapping[str, str]: - """Return a mapping of extension names and statuses to their categories.""" - categories = {} - - for ext in EXTENSIONS: - if ext in self.bot.extensions: - status = Emojis.status_online - else: - status = Emojis.status_offline - - path = ext.split(".") - if len(path) > COG_PATH_LEN + 1: - category = " - ".join(path[COG_PATH_LEN:-1]) - else: - category = "uncategorised" - - categories.setdefault(category, []).append(f"{status} {path[-1]}") - - return categories - - def batch_manage(self, action: Action, *extensions: str) -> str: - """ - Apply an action to multiple extensions and return a message with the results. - - If only one extension is given, it is deferred to `manage()`. - """ - if len(extensions) == 1: - msg, _ = self.manage(action, extensions[0]) - return msg - - verb = action.name.lower() - failures = {} - - for extension in extensions: - _, error = self.manage(action, extension) - if error: - failures[extension] = error - - emoji = ":x:" if failures else ":ok_hand:" - msg = f"{emoji} {len(extensions) - len(failures)} / {len(extensions)} extensions {verb}ed." - - if failures: - failures = "\n".join(f"{ext}\n {err}" for ext, err in failures.items()) - msg += f"\nFailures:```{failures}```" - - log.debug(f"Batch {verb}ed extensions.") - - return msg - - def manage(self, action: Action, ext: str) -> t.Tuple[str, t.Optional[str]]: - """Apply an action to an extension and return the status message and any error message.""" - verb = action.name.lower() - error_msg = None - - try: - action.value(self.bot, ext) - except (commands.ExtensionAlreadyLoaded, commands.ExtensionNotLoaded): - if action is Action.RELOAD: - # When reloading, just load the extension if it was not loaded. - return self.manage(Action.LOAD, ext) - - msg = f":x: Extension `{ext}` is already {verb}ed." - log.debug(msg[4:]) - except Exception as e: - if hasattr(e, "original"): - e = e.original - - log.exception(f"Extension '{ext}' failed to {verb}.") - - error_msg = f"{e.__class__.__name__}: {e}" - msg = f":x: Failed to {verb} extension `{ext}`:\n```{error_msg}```" - else: - msg = f":ok_hand: Extension successfully {verb}ed: `{ext}`." - log.debug(msg[10:]) - - return msg, error_msg - - # This cannot be static (must have a __func__ attribute). - def cog_check(self, ctx: Context) -> bool: - """Only allow moderators and core developers to invoke the commands in this cog.""" - return with_role_check(ctx, *MODERATION_ROLES, Roles.core_developers) - - # This cannot be static (must have a __func__ attribute). - async def cog_command_error(self, ctx: Context, error: Exception) -> None: - """Handle BadArgument errors locally to prevent the help command from showing.""" - if isinstance(error, commands.BadArgument): - await ctx.send(str(error)) - error.handled = True - - -def setup(bot: Bot) -> None: - """Load the Extensions cog.""" - bot.add_cog(Extensions(bot)) diff --git a/bot/cogs/utils/jams.py b/bot/cogs/utils/jams.py deleted file mode 100644 index b3102db2f..000000000 --- a/bot/cogs/utils/jams.py +++ /dev/null @@ -1,150 +0,0 @@ -import logging -import typing as t - -from discord import CategoryChannel, Guild, Member, PermissionOverwrite, Role -from discord.ext import commands -from more_itertools import unique_everseen - -from bot.bot import Bot -from bot.constants import Roles -from bot.decorators import with_role - -log = logging.getLogger(__name__) - -MAX_CHANNELS = 50 -CATEGORY_NAME = "Code Jam" - - -class CodeJams(commands.Cog): - """Manages the code-jam related parts of our server.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @commands.command() - @with_role(Roles.admins) - async def createteam(self, ctx: commands.Context, team_name: str, members: commands.Greedy[Member]) -> None: - """ - Create team channels (voice and text) in the Code Jams category, assign roles, and add overwrites for the team. - - The first user passed will always be the team leader. - """ - # Ignore duplicate members - members = list(unique_everseen(members)) - - # We had a little issue during Code Jam 4 here, the greedy converter did it's job - # and ignored anything which wasn't a valid argument which left us with teams of - # two members or at some times even 1 member. This fixes that by checking that there - # are always 3 members in the members list. - if len(members) < 3: - await ctx.send( - ":no_entry_sign: One of your arguments was invalid\n" - f"There must be a minimum of 3 valid members in your team. Found: {len(members)}" - " members" - ) - return - - team_channel = await self.create_channels(ctx.guild, team_name, members) - await self.add_roles(ctx.guild, members) - - await ctx.send( - f":ok_hand: Team created: {team_channel}\n" - f"**Team Leader:** {members[0].mention}\n" - f"**Team Members:** {' '.join(member.mention for member in members[1:])}" - ) - - async def get_category(self, guild: Guild) -> CategoryChannel: - """ - Return a code jam category. - - If all categories are full or none exist, create a new category. - """ - for category in guild.categories: - # Need 2 available spaces: one for the text channel and one for voice. - if category.name == CATEGORY_NAME and MAX_CHANNELS - len(category.channels) >= 2: - return category - - return await self.create_category(guild) - - @staticmethod - async def create_category(guild: Guild) -> CategoryChannel: - """Create a new code jam category and return it.""" - log.info("Creating a new code jam category.") - - category_overwrites = { - guild.default_role: PermissionOverwrite(read_messages=False), - guild.me: PermissionOverwrite(read_messages=True) - } - - return await guild.create_category_channel( - CATEGORY_NAME, - overwrites=category_overwrites, - reason="It's code jam time!" - ) - - @staticmethod - def get_overwrites(members: t.List[Member], guild: Guild) -> t.Dict[t.Union[Member, Role], PermissionOverwrite]: - """Get code jam team channels permission overwrites.""" - # First member is always the team leader - team_channel_overwrites = { - members[0]: PermissionOverwrite( - manage_messages=True, - read_messages=True, - manage_webhooks=True, - connect=True - ), - guild.default_role: PermissionOverwrite(read_messages=False, connect=False), - guild.get_role(Roles.verified): PermissionOverwrite( - read_messages=False, - connect=False - ) - } - - # Rest of members should just have read_messages - for member in members[1:]: - team_channel_overwrites[member] = PermissionOverwrite( - read_messages=True, - connect=True - ) - - return team_channel_overwrites - - async def create_channels(self, guild: Guild, team_name: str, members: t.List[Member]) -> str: - """Create team text and voice channels. Return the mention for the text channel.""" - # Get permission overwrites and category - team_channel_overwrites = self.get_overwrites(members, guild) - code_jam_category = await self.get_category(guild) - - # Create a text channel for the team - team_channel = await guild.create_text_channel( - team_name, - overwrites=team_channel_overwrites, - category=code_jam_category - ) - - # Create a voice channel for the team - team_voice_name = " ".join(team_name.split("-")).title() - - await guild.create_voice_channel( - team_voice_name, - overwrites=team_channel_overwrites, - category=code_jam_category - ) - - return team_channel.mention - - @staticmethod - async def add_roles(guild: Guild, members: t.List[Member]) -> None: - """Assign team leader and jammer roles.""" - # Assign team leader role - await members[0].add_roles(guild.get_role(Roles.team_leaders)) - - # Assign rest of roles - jammer_role = guild.get_role(Roles.jammers) - for member in members: - await member.add_roles(jammer_role) - - -def setup(bot: Bot) -> None: - """Load the CodeJams cog.""" - bot.add_cog(CodeJams(bot)) diff --git a/bot/cogs/utils/reminders.py b/bot/cogs/utils/reminders.py deleted file mode 100644 index 670493bcf..000000000 --- a/bot/cogs/utils/reminders.py +++ /dev/null @@ -1,427 +0,0 @@ -import asyncio -import logging -import random -import textwrap -import typing as t -from datetime import datetime, timedelta -from operator import itemgetter - -import discord -from dateutil.parser import isoparse -from dateutil.relativedelta import relativedelta -from discord.ext.commands import Cog, Context, Greedy, group - -from bot.bot import Bot -from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, STAFF_ROLES -from bot.converters import Duration -from bot.pagination import LinePaginator -from bot.utils.checks import without_role_check -from bot.utils.messages import send_denial -from bot.utils.scheduling import Scheduler -from bot.utils.time import humanize_delta - -log = logging.getLogger(__name__) - -WHITELISTED_CHANNELS = Guild.reminder_whitelist -MAXIMUM_REMINDERS = 5 - -Mentionable = t.Union[discord.Member, discord.Role] - - -class Reminders(Cog): - """Provide in-channel reminder functionality.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.scheduler = Scheduler(self.__class__.__name__) - - self.bot.loop.create_task(self.reschedule_reminders()) - - def cog_unload(self) -> None: - """Cancel scheduled tasks.""" - self.scheduler.cancel_all() - - async def reschedule_reminders(self) -> None: - """Get all current reminders from the API and reschedule them.""" - await self.bot.wait_until_guild_available() - response = await self.bot.api_client.get( - 'bot/reminders', - params={'active': 'true'} - ) - - now = datetime.utcnow() - - for reminder in response: - is_valid, *_ = self.ensure_valid_reminder(reminder, cancel_task=False) - if not is_valid: - continue - - remind_at = isoparse(reminder['expiration']).replace(tzinfo=None) - - # If the reminder is already overdue ... - if remind_at < now: - late = relativedelta(now, remind_at) - await self.send_reminder(reminder, late) - else: - self.schedule_reminder(reminder) - - def ensure_valid_reminder( - self, - reminder: dict, - cancel_task: bool = True - ) -> t.Tuple[bool, discord.User, discord.TextChannel]: - """Ensure reminder author and channel can be fetched otherwise delete the reminder.""" - user = self.bot.get_user(reminder['author']) - channel = self.bot.get_channel(reminder['channel_id']) - is_valid = True - if not user or not channel: - is_valid = False - log.info( - f"Reminder {reminder['id']} invalid: " - f"User {reminder['author']}={user}, Channel {reminder['channel_id']}={channel}." - ) - asyncio.create_task(self._delete_reminder(reminder['id'], cancel_task)) - - return is_valid, user, channel - - @staticmethod - async def _send_confirmation( - ctx: Context, - on_success: str, - reminder_id: str, - 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 - - 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')}" - - embed.set_footer(text=footer_str) - - await ctx.send(embed=embed) - - @staticmethod - async def _check_mentions(ctx: Context, mentions: t.Iterable[Mentionable]) -> t.Tuple[bool, str]: - """ - Returns whether or not the list of mentions is allowed. - - Conditions: - - Role reminders are Mods+ - - Reminders for other users are Helpers+ - - If mentions aren't allowed, also return the type of mention(s) disallowed. - """ - if without_role_check(ctx, *STAFF_ROLES): - return False, "members/roles" - elif without_role_check(ctx, *MODERATION_ROLES): - return all(isinstance(mention, discord.Member) for mention in mentions), "roles" - else: - return True, "" - - @staticmethod - async def validate_mentions(ctx: Context, mentions: t.Iterable[Mentionable]) -> bool: - """ - Filter mentions to see if the user can mention, and sends a denial if not allowed. - - Returns whether or not the validation is successful. - """ - mentions_allowed, disallowed_mentions = await Reminders._check_mentions(ctx, mentions) - - if not mentions or mentions_allowed: - return True - else: - await send_denial(ctx, f"You can't mention other {disallowed_mentions} in your reminder!") - return False - - def get_mentionables(self, mention_ids: t.List[int]) -> t.Iterator[Mentionable]: - """Converts Role and Member ids to their corresponding objects if possible.""" - guild = self.bot.get_guild(Guild.id) - for mention_id in mention_ids: - if (mentionable := (guild.get_member(mention_id) or guild.get_role(mention_id))): - yield mentionable - - def schedule_reminder(self, reminder: dict) -> None: - """A coroutine which sends the reminder once the time is reached, and cancels the running task.""" - reminder_id = reminder["id"] - reminder_datetime = isoparse(reminder['expiration']).replace(tzinfo=None) - - async def _remind() -> None: - await self.send_reminder(reminder) - - log.debug(f"Deleting reminder {reminder_id} (the user has been reminded).") - await self._delete_reminder(reminder_id) - - self.scheduler.schedule_at(reminder_datetime, reminder_id, _remind()) - - async def _delete_reminder(self, reminder_id: str, cancel_task: bool = True) -> None: - """Delete a reminder from the database, given its ID, and cancel the running task.""" - await self.bot.api_client.delete('bot/reminders/' + str(reminder_id)) - - if cancel_task: - # Now we can remove it from the schedule list - self.scheduler.cancel(reminder_id) - - async def _edit_reminder(self, reminder_id: int, payload: dict) -> dict: - """ - Edits a reminder in the database given the ID and payload. - - Returns the edited reminder. - """ - # Send the request to update the reminder in the database - reminder = await self.bot.api_client.patch( - 'bot/reminders/' + str(reminder_id), - json=payload - ) - return reminder - - async def _reschedule_reminder(self, reminder: dict) -> None: - """Reschedule a reminder object.""" - log.trace(f"Cancelling old task #{reminder['id']}") - self.scheduler.cancel(reminder["id"]) - - log.trace(f"Scheduling new task #{reminder['id']}") - self.schedule_reminder(reminder) - - async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: - """Send the reminder.""" - is_valid, user, channel = self.ensure_valid_reminder(reminder) - if not is_valid: - return - - embed = discord.Embed() - embed.colour = discord.Colour.blurple() - embed.set_author( - icon_url=Icons.remind_blurple, - name="It has arrived!" - ) - - embed.description = f"Here's your reminder: `{reminder['content']}`." - - if reminder.get("jump_url"): # keep backward compatibility - embed.description += f"\n[Jump back to when you created the reminder]({reminder['jump_url']})" - - if late: - embed.colour = discord.Colour.red() - embed.set_author( - icon_url=Icons.remind_red, - name=f"Sorry it arrived {humanize_delta(late, max_units=2)} late!" - ) - - additional_mentions = ' '.join( - mentionable.mention for mentionable in self.get_mentionables(reminder["mentions"]) - ) - - await channel.send( - content=f"{user.mention} {additional_mentions}", - embed=embed - ) - await self._delete_reminder(reminder["id"]) - - @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True) - async def remind_group( - self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str - ) -> None: - """Commands for managing your reminders.""" - await ctx.invoke(self.new_reminder, mentions=mentions, expiration=expiration, content=content) - - @remind_group.command(name="new", aliases=("add", "create")) - async def new_reminder( - self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str - ) -> None: - """ - Set yourself a simple reminder. - - Expiration is parsed per: http://strftime.org/ - """ - # If the user is not staff, we need to verify whether or not to make a reminder at all. - if without_role_check(ctx, *STAFF_ROLES): - - # If they don't have permission to set a reminder in this channel - if ctx.channel.id not in WHITELISTED_CHANNELS: - await send_denial(ctx, "Sorry, you can't do that here!") - return - - # Get their current active reminders - active_reminders = await self.bot.api_client.get( - 'bot/reminders', - params={ - 'author__id': str(ctx.author.id) - } - ) - - # Let's limit this, so we don't get 10 000 - # reminders from kip or something like that :P - if len(active_reminders) > MAXIMUM_REMINDERS: - await send_denial(ctx, "You have too many active reminders!") - return - - # Remove duplicate mentions - mentions = set(mentions) - mentions.discard(ctx.author) - - # Filter mentions to see if the user can mention members/roles - if not await self.validate_mentions(ctx, mentions): - return - - mention_ids = [mention.id for mention in mentions] - - # Now we can attempt to actually set the reminder. - reminder = await self.bot.api_client.post( - 'bot/reminders', - json={ - 'author': ctx.author.id, - 'channel_id': ctx.message.channel.id, - 'jump_url': ctx.message.jump_url, - 'content': content, - 'expiration': expiration.isoformat(), - 'mentions': mention_ids, - } - ) - - now = datetime.utcnow() - timedelta(seconds=1) - humanized_delta = humanize_delta(relativedelta(expiration, now)) - mention_string = ( - f"Your reminder will arrive in {humanized_delta} " - f"and will mention {len(mentions)} other(s)!" - ) - - # Confirm to the user that it worked. - await self._send_confirmation( - ctx, - on_success=mention_string, - reminder_id=reminder["id"], - delivery_dt=expiration, - ) - - self.schedule_reminder(reminder) - - @remind_group.command(name="list") - async def list_reminders(self, ctx: Context) -> None: - """View a paginated embed of all reminders for your user.""" - # Get all the user's reminders from the database. - data = await self.bot.api_client.get( - 'bot/reminders', - params={'author__id': str(ctx.author.id)} - ) - - now = datetime.utcnow() - - # Make a list of tuples so it can be sorted by time. - reminders = sorted( - ( - (rem['content'], rem['expiration'], rem['id'], rem['mentions']) - for rem in data - ), - key=itemgetter(1) - ) - - lines = [] - - for content, remind_at, id_, mentions in reminders: - # Parse and humanize the time, make it pretty :D - remind_datetime = isoparse(remind_at).replace(tzinfo=None) - time = humanize_delta(relativedelta(remind_datetime, now)) - - mentions = ", ".join( - # Both Role and User objects have the `name` attribute - mention.name for mention in self.get_mentionables(mentions) - ) - mention_string = f"\n**Mentions:** {mentions}" if mentions else "" - - text = textwrap.dedent(f""" - **Reminder #{id_}:** *expires in {time}* (ID: {id_}){mention_string} - {content} - """).strip() - - lines.append(text) - - embed = discord.Embed() - embed.colour = discord.Colour.blurple() - embed.title = f"Reminders for {ctx.author}" - - # Remind the user that they have no reminders :^) - if not lines: - embed.description = "No active reminders could be found." - await ctx.send(embed=embed) - return - - # Construct the embed and paginate it. - embed.colour = discord.Colour.blurple() - - await LinePaginator.paginate( - lines, - ctx, embed, - max_lines=3, - empty=True - ) - - @remind_group.group(name="edit", aliases=("change", "modify"), invoke_without_command=True) - async def edit_reminder_group(self, ctx: Context) -> None: - """Commands for modifying your current reminders.""" - await ctx.send_help(ctx.command) - - @edit_reminder_group.command(name="duration", aliases=("time",)) - async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: Duration) -> None: - """ - Edit one of your reminder's expiration. - - Expiration is parsed per: http://strftime.org/ - """ - await self.edit_reminder(ctx, id_, {'expiration': expiration.isoformat()}) - - @edit_reminder_group.command(name="content", aliases=("reason",)) - async def edit_reminder_content(self, ctx: Context, id_: int, *, content: str) -> None: - """Edit one of your reminder's content.""" - await self.edit_reminder(ctx, id_, {"content": content}) - - @edit_reminder_group.command(name="mentions", aliases=("pings",)) - async def edit_reminder_mentions(self, ctx: Context, id_: int, mentions: Greedy[Mentionable]) -> None: - """Edit one of your reminder's mentions.""" - # Remove duplicate mentions - mentions = set(mentions) - mentions.discard(ctx.author) - - # Filter mentions to see if the user can mention members/roles - if not await self.validate_mentions(ctx, mentions): - return - - mention_ids = [mention.id for mention in mentions] - await self.edit_reminder(ctx, id_, {"mentions": mention_ids}) - - async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: - """Edits a reminder with the given payload, then sends a confirmation message.""" - reminder = await self._edit_reminder(id_, payload) - - # Parse the reminder expiration back into a datetime - expiration = isoparse(reminder["expiration"]).replace(tzinfo=None) - - # Send a confirmation message to the channel - await self._send_confirmation( - ctx, - on_success="That reminder has been edited successfully!", - reminder_id=id_, - delivery_dt=expiration, - ) - await self._reschedule_reminder(reminder) - - @remind_group.command("delete", aliases=("remove", "cancel")) - async def delete_reminder(self, ctx: Context, id_: int) -> None: - """Delete one of your active reminders.""" - await self._delete_reminder(id_) - await self._send_confirmation( - ctx, - on_success="That reminder has been deleted successfully!", - reminder_id=id_, - delivery_dt=None, - ) - - -def setup(bot: Bot) -> None: - """Load the Reminders cog.""" - bot.add_cog(Reminders(bot)) diff --git a/bot/cogs/utils/snekbox.py b/bot/cogs/utils/snekbox.py deleted file mode 100644 index 52c8b6f88..000000000 --- a/bot/cogs/utils/snekbox.py +++ /dev/null @@ -1,349 +0,0 @@ -import asyncio -import contextlib -import datetime -import logging -import re -import textwrap -from functools import partial -from signal import Signals -from typing import Optional, Tuple - -from discord import HTTPException, Message, NotFound, Reaction, User -from discord.ext.commands import Cog, Context, command, guild_only - -from bot.bot import Bot -from bot.constants import Categories, Channels, Roles, URLs -from bot.decorators import in_whitelist -from bot.utils.messages import wait_for_deletion - -log = logging.getLogger(__name__) - -ESCAPE_REGEX = re.compile("[`\u202E\u200B]{3,}") -FORMATTED_CODE_REGEX = re.compile( - r"^\s*" # any leading whitespace from the beginning of the string - r"(?P(?P```)|``?)" # code delimiter: 1-3 backticks; (?P=block) only matches if it's a block - r"(?(block)(?:(?P[a-z]+)\n)?)" # if we're in a block, match optional language (only letters plus newline) - r"(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code - r"(?P.*?)" # extract all code inside the markup - r"\s*" # any more whitespace before the end of the code markup - r"(?P=delim)" # match the exact same delimiter from the start again - r"\s*$", # any trailing whitespace until the end of the string - re.DOTALL | re.IGNORECASE # "." also matches newlines, case insensitive -) -RAW_CODE_REGEX = re.compile( - r"^(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code - r"(?P.*?)" # extract all the rest as code - r"\s*$", # any trailing whitespace until the end of the string - re.DOTALL # "." also matches newlines -) - -MAX_PASTE_LEN = 1000 - -# `!eval` command whitelists -EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric) -EVAL_CATEGORIES = (Categories.help_available, Categories.help_in_use) -EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners) - -SIGKILL = 9 - -REEVAL_EMOJI = '\U0001f501' # :repeat: -REEVAL_TIMEOUT = 30 - - -class Snekbox(Cog): - """Safe evaluation of Python code using Snekbox.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.jobs = {} - - async def post_eval(self, code: str) -> dict: - """Send a POST request to the Snekbox API to evaluate code and return the results.""" - url = URLs.snekbox_eval_api - data = {"input": code} - async with self.bot.http_session.post(url, json=data, raise_for_status=True) as resp: - return await resp.json() - - async def upload_output(self, output: str) -> Optional[str]: - """Upload the eval output to a paste service and return a URL to it if successful.""" - log.trace("Uploading full output to paste service...") - - if len(output) > MAX_PASTE_LEN: - log.info("Full output is too long to upload") - return "too long to upload" - - url = URLs.paste_service.format(key="documents") - try: - async with self.bot.http_session.post(url, data=output, raise_for_status=True) as resp: - data = await resp.json() - - if "key" in data: - return URLs.paste_service.format(key=data["key"]) - except Exception: - # 400 (Bad Request) means there are too many characters - log.exception("Failed to upload full output to paste service!") - - @staticmethod - def prepare_input(code: str) -> str: - """Extract code from the Markdown, format it, and insert it into the code template.""" - match = FORMATTED_CODE_REGEX.fullmatch(code) - if match: - code, block, lang, delim = match.group("code", "block", "lang", "delim") - code = textwrap.dedent(code) - if block: - info = (f"'{lang}' highlighted" if lang else "plain") + " code block" - else: - info = f"{delim}-enclosed inline code" - log.trace(f"Extracted {info} for evaluation:\n{code}") - else: - code = textwrap.dedent(RAW_CODE_REGEX.fullmatch(code).group("code")) - log.trace( - f"Eval message contains unformatted or badly formatted code, " - f"stripping whitespace only:\n{code}" - ) - - return code - - @staticmethod - def get_results_message(results: dict) -> Tuple[str, str]: - """Return a user-friendly message and error corresponding to the process's return code.""" - stdout, returncode = results["stdout"], results["returncode"] - msg = f"Your eval job has completed with return code {returncode}" - error = "" - - if returncode is None: - msg = "Your eval job has failed" - error = stdout.strip() - elif returncode == 128 + SIGKILL: - msg = "Your eval job timed out or ran out of memory" - elif returncode == 255: - msg = "Your eval job has failed" - error = "A fatal NsJail error occurred" - else: - # Try to append signal's name if one exists - try: - name = Signals(returncode - 128).name - msg = f"{msg} ({name})" - except ValueError: - pass - - return msg, error - - @staticmethod - def get_status_emoji(results: dict) -> str: - """Return an emoji corresponding to the status code or lack of output in result.""" - if not results["stdout"].strip(): # No output - return ":warning:" - elif results["returncode"] == 0: # No error - return ":white_check_mark:" - else: # Exception - return ":x:" - - async def format_output(self, output: str) -> Tuple[str, Optional[str]]: - """ - Format the output and return a tuple of the formatted output and a URL to the full output. - - Prepend each line with a line number. Truncate if there are over 10 lines or 1000 characters - and upload the full output to a paste service. - """ - log.trace("Formatting output...") - - output = output.rstrip("\n") - original_output = output # To be uploaded to a pasting service if needed - paste_link = None - - if "<@" in output: - output = output.replace("<@", "<@\u200B") # Zero-width space - - if " 0: - output = [f"{i:03d} | {line}" for i, line in enumerate(output.split('\n'), 1)] - output = output[:11] # Limiting to only 11 lines - output = "\n".join(output) - - if lines > 10: - truncated = True - if len(output) >= 1000: - output = f"{output[:1000]}\n... (truncated - too long, too many lines)" - else: - output = f"{output}\n... (truncated - too many lines)" - elif len(output) >= 1000: - truncated = True - output = f"{output[:1000]}\n... (truncated - too long)" - - if truncated: - paste_link = await self.upload_output(original_output) - - output = output or "[No output]" - - return output, paste_link - - async def send_eval(self, ctx: Context, code: str) -> Message: - """ - Evaluate code, format it, and send the output to the corresponding channel. - - Return the bot response. - """ - async with ctx.typing(): - results = await self.post_eval(code) - msg, error = self.get_results_message(results) - - if error: - output, paste_link = error, None - else: - output, paste_link = await self.format_output(results["stdout"]) - - icon = self.get_status_emoji(results) - msg = f"{ctx.author.mention} {icon} {msg}.\n\n```\n{output}\n```" - if paste_link: - msg = f"{msg}\nFull output: {paste_link}" - - # Collect stats of eval fails + successes - if icon == ":x:": - self.bot.stats.incr("snekbox.python.fail") - else: - self.bot.stats.incr("snekbox.python.success") - - filter_cog = self.bot.get_cog("Filtering") - filter_triggered = False - if filter_cog: - filter_triggered = await filter_cog.filter_eval(msg, ctx.message) - if filter_triggered: - response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.") - else: - response = await ctx.send(msg) - self.bot.loop.create_task( - wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot) - ) - - log.info(f"{ctx.author}'s job had a return code of {results['returncode']}") - return response - - async def continue_eval(self, ctx: Context, response: Message) -> Optional[str]: - """ - Check if the eval session should continue. - - Return the new code to evaluate or None if the eval session should be terminated. - """ - _predicate_eval_message_edit = partial(predicate_eval_message_edit, ctx) - _predicate_emoji_reaction = partial(predicate_eval_emoji_reaction, ctx) - - with contextlib.suppress(NotFound): - try: - _, new_message = await self.bot.wait_for( - 'message_edit', - check=_predicate_eval_message_edit, - timeout=REEVAL_TIMEOUT - ) - await ctx.message.add_reaction(REEVAL_EMOJI) - await self.bot.wait_for( - 'reaction_add', - check=_predicate_emoji_reaction, - timeout=10 - ) - - code = await self.get_code(new_message) - await ctx.message.clear_reactions() - with contextlib.suppress(HTTPException): - await response.delete() - - except asyncio.TimeoutError: - await ctx.message.clear_reactions() - return None - - return code - - async def get_code(self, message: Message) -> Optional[str]: - """ - Return the code from `message` to be evaluated. - - If the message is an invocation of the eval command, return the first argument or None if it - doesn't exist. Otherwise, return the full content of the message. - """ - log.trace(f"Getting context for message {message.id}.") - new_ctx = await self.bot.get_context(message) - - if new_ctx.command is self.eval_command: - log.trace(f"Message {message.id} invokes eval command.") - split = message.content.split(maxsplit=1) - code = split[1] if len(split) > 1 else None - else: - log.trace(f"Message {message.id} does not invoke eval command.") - code = message.content - - return code - - @command(name="eval", aliases=("e",)) - @guild_only() - @in_whitelist(channels=EVAL_CHANNELS, categories=EVAL_CATEGORIES, roles=EVAL_ROLES) - async def eval_command(self, ctx: Context, *, code: str = None) -> None: - """ - Run Python code and get the results. - - This command supports multiple lines of code, including code wrapped inside a formatted code - block. Code can be re-evaluated by editing the original message within 10 seconds and - clicking the reaction that subsequently appears. - - We've done our best to make this sandboxed, but do let us know if you manage to find an - issue with it! - """ - if ctx.author.id in self.jobs: - await ctx.send( - f"{ctx.author.mention} You've already got a job running - " - "please wait for it to finish!" - ) - return - - if not code: # None or empty string - await ctx.send_help(ctx.command) - return - - if Roles.helpers in (role.id for role in ctx.author.roles): - self.bot.stats.incr("snekbox_usages.roles.helpers") - else: - self.bot.stats.incr("snekbox_usages.roles.developers") - - if ctx.channel.category_id == Categories.help_in_use: - self.bot.stats.incr("snekbox_usages.channels.help") - elif ctx.channel.id == Channels.bot_commands: - self.bot.stats.incr("snekbox_usages.channels.bot_commands") - else: - self.bot.stats.incr("snekbox_usages.channels.topical") - - log.info(f"Received code from {ctx.author} for evaluation:\n{code}") - - while True: - self.jobs[ctx.author.id] = datetime.datetime.now() - code = self.prepare_input(code) - try: - response = await self.send_eval(ctx, code) - finally: - del self.jobs[ctx.author.id] - - code = await self.continue_eval(ctx, response) - if not code: - break - log.info(f"Re-evaluating code from message {ctx.message.id}:\n{code}") - - -def predicate_eval_message_edit(ctx: Context, old_msg: Message, new_msg: Message) -> bool: - """Return True if the edited message is the context message and the content was indeed modified.""" - return new_msg.id == ctx.message.id and old_msg.content != new_msg.content - - -def predicate_eval_emoji_reaction(ctx: Context, reaction: Reaction, user: User) -> bool: - """Return True if the reaction REEVAL_EMOJI was added by the context message author on this message.""" - return reaction.message.id == ctx.message.id and user.id == ctx.author.id and str(reaction) == REEVAL_EMOJI - - -def setup(bot: Bot) -> None: - """Load the Snekbox cog.""" - bot.add_cog(Snekbox(bot)) diff --git a/bot/cogs/utils/utils.py b/bot/cogs/utils/utils.py deleted file mode 100644 index d96abbd5a..000000000 --- a/bot/cogs/utils/utils.py +++ /dev/null @@ -1,265 +0,0 @@ -import difflib -import logging -import re -import unicodedata -from email.parser import HeaderParser -from io import StringIO -from typing import Tuple, Union - -from discord import Colour, Embed, utils -from discord.ext.commands import BadArgument, Cog, Context, clean_content, command - -from bot.bot import Bot -from bot.constants import Channels, MODERATION_ROLES, STAFF_ROLES -from bot.decorators import in_whitelist, with_role -from bot.pagination import LinePaginator -from bot.utils import messages - -log = logging.getLogger(__name__) - -ZEN_OF_PYTHON = """\ -Beautiful is better than ugly. -Explicit is better than implicit. -Simple is better than complex. -Complex is better than complicated. -Flat is better than nested. -Sparse is better than dense. -Readability counts. -Special cases aren't special enough to break the rules. -Although practicality beats purity. -Errors should never pass silently. -Unless explicitly silenced. -In the face of ambiguity, refuse the temptation to guess. -There should be one-- and preferably only one --obvious way to do it. -Although that way may not be obvious at first unless you're Dutch. -Now is better than never. -Although never is often better than *right* now. -If the implementation is hard to explain, it's a bad idea. -If the implementation is easy to explain, it may be a good idea. -Namespaces are one honking great idea -- let's do more of those! -""" - -ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png" - - -class Utils(Cog): - """A selection of utilities which don't have a clear category.""" - - def __init__(self, bot: Bot): - self.bot = bot - - self.base_pep_url = "http://www.python.org/dev/peps/pep-" - self.base_github_pep_url = "https://raw.githubusercontent.com/python/peps/master/pep-" - - @command(name='pep', aliases=('get_pep', 'p')) - async def pep_command(self, ctx: Context, pep_number: str) -> None: - """Fetches information about a PEP and sends it to the channel.""" - if pep_number.isdigit(): - pep_number = int(pep_number) - else: - await ctx.send_help(ctx.command) - return - - # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs. - if pep_number == 0: - return await self.send_pep_zero(ctx) - - possible_extensions = ['.txt', '.rst'] - found_pep = False - for extension in possible_extensions: - # Attempt to fetch the PEP - pep_url = f"{self.base_github_pep_url}{pep_number:04}{extension}" - log.trace(f"Requesting PEP {pep_number} with {pep_url}") - response = await self.bot.http_session.get(pep_url) - - if response.status == 200: - log.trace("PEP found") - found_pep = True - - pep_content = await response.text() - - # Taken from https://github.com/python/peps/blob/master/pep0/pep.py#L179 - pep_header = HeaderParser().parse(StringIO(pep_content)) - - # Assemble the embed - pep_embed = Embed( - title=f"**PEP {pep_number} - {pep_header['Title']}**", - description=f"[Link]({self.base_pep_url}{pep_number:04})", - ) - - pep_embed.set_thumbnail(url=ICON_URL) - - # Add the interesting information - fields_to_check = ("Status", "Python-Version", "Created", "Type") - for field in fields_to_check: - # Check for a PEP metadata field that is present but has an empty value - # embed field values can't contain an empty string - if pep_header.get(field, ""): - pep_embed.add_field(name=field, value=pep_header[field]) - - elif response.status != 404: - # any response except 200 and 404 is expected - found_pep = True # actually not, but it's easier to display this way - log.trace(f"The user requested PEP {pep_number}, but the response had an unexpected status code: " - f"{response.status}.\n{response.text}") - - error_message = "Unexpected HTTP error during PEP search. Please let us know." - pep_embed = Embed(title="Unexpected error", description=error_message) - pep_embed.colour = Colour.red() - break - - if not found_pep: - log.trace("PEP was not found") - not_found = f"PEP {pep_number} does not exist." - pep_embed = Embed(title="PEP not found", description=not_found) - pep_embed.colour = Colour.red() - - await ctx.message.channel.send(embed=pep_embed) - - @command() - @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES) - async def charinfo(self, ctx: Context, *, characters: str) -> None: - """Shows you information on up to 50 unicode characters.""" - match = re.match(r"<(a?):(\w+):(\d+)>", characters) - if match: - return await messages.send_denial( - ctx, - "**Non-Character Detected**\n" - "Only unicode characters can be processed, but a custom Discord emoji " - "was found. Please remove it and try again." - ) - - if len(characters) > 50: - return await messages.send_denial(ctx, f"Too many characters ({len(characters)}/50)") - - def get_info(char: str) -> Tuple[str, str]: - digit = f"{ord(char):x}" - if len(digit) <= 4: - u_code = f"\\u{digit:>04}" - else: - u_code = f"\\U{digit:>08}" - url = f"https://www.compart.com/en/unicode/U+{digit:>04}" - name = f"[{unicodedata.name(char, '')}]({url})" - info = f"`{u_code.ljust(10)}`: {name} - {utils.escape_markdown(char)}" - return info, u_code - - char_list, raw_list = zip(*(get_info(c) for c in characters)) - embed = Embed().set_author(name="Character Info") - - if len(characters) > 1: - # Maximum length possible is 502 out of 1024, so there's no need to truncate. - embed.add_field(name='Full Raw Text', value=f"`{''.join(raw_list)}`", inline=False) - - await LinePaginator.paginate(char_list, ctx, embed, max_lines=10, max_size=2000, empty=False) - - @command() - async def zen(self, ctx: Context, *, search_value: Union[int, str, None] = None) -> None: - """ - Show the Zen of Python. - - Without any arguments, the full Zen will be produced. - If an integer is provided, the line with that index will be produced. - If a string is provided, the line which matches best will be produced. - """ - embed = Embed( - colour=Colour.blurple(), - title="The Zen of Python", - description=ZEN_OF_PYTHON - ) - - if search_value is None: - embed.title += ", by Tim Peters" - await ctx.send(embed=embed) - return - - zen_lines = ZEN_OF_PYTHON.splitlines() - - # handle if it's an index int - if isinstance(search_value, int): - upper_bound = len(zen_lines) - 1 - lower_bound = -1 * upper_bound - if not (lower_bound <= search_value <= upper_bound): - raise BadArgument(f"Please provide an index between {lower_bound} and {upper_bound}.") - - embed.title += f" (line {search_value % len(zen_lines)}):" - embed.description = zen_lines[search_value] - await ctx.send(embed=embed) - return - - # Try to handle first exact word due difflib.SequenceMatched may use some other similar word instead - # exact word. - for i, line in enumerate(zen_lines): - for word in line.split(): - if word.lower() == search_value.lower(): - embed.title += f" (line {i}):" - embed.description = line - await ctx.send(embed=embed) - return - - # handle if it's a search string and not exact word - matcher = difflib.SequenceMatcher(None, search_value.lower()) - - best_match = "" - match_index = 0 - best_ratio = 0 - - for index, line in enumerate(zen_lines): - matcher.set_seq2(line.lower()) - - # the match ratio needs to be adjusted because, naturally, - # longer lines will have worse ratios than shorter lines when - # fuzzy searching for keywords. this seems to work okay. - adjusted_ratio = (len(line) - 5) ** 0.5 * matcher.ratio() - - if adjusted_ratio > best_ratio: - best_ratio = adjusted_ratio - best_match = line - match_index = index - - if not best_match: - raise BadArgument("I didn't get a match! Please try again with a different search term.") - - embed.title += f" (line {match_index}):" - embed.description = best_match - await ctx.send(embed=embed) - - @command(aliases=("poll",)) - @with_role(*MODERATION_ROLES) - async def vote(self, ctx: Context, title: clean_content(fix_channel_mentions=True), *options: str) -> None: - """ - Build a quick voting poll with matching reactions with the provided options. - - A maximum of 20 options can be provided, as Discord supports a max of 20 - reactions on a single message. - """ - if len(title) > 256: - raise BadArgument("The title cannot be longer than 256 characters.") - if len(options) < 2: - raise BadArgument("Please provide at least 2 options.") - if len(options) > 20: - raise BadArgument("I can only handle 20 options!") - - codepoint_start = 127462 # represents "regional_indicator_a" unicode value - options = {chr(i): f"{chr(i)} - {v}" for i, v in enumerate(options, start=codepoint_start)} - embed = Embed(title=title, description="\n".join(options.values())) - message = await ctx.send(embed=embed) - for reaction in options: - await message.add_reaction(reaction) - - async def send_pep_zero(self, ctx: Context) -> None: - """Send information about PEP 0.""" - pep_embed = Embed( - title="**PEP 0 - Index of Python Enhancement Proposals (PEPs)**", - description="[Link](https://www.python.org/dev/peps/)" - ) - pep_embed.set_thumbnail(url=ICON_URL) - pep_embed.add_field(name="Status", value="Active") - pep_embed.add_field(name="Created", value="13-Jul-2000") - pep_embed.add_field(name="Type", value="Informational") - - await ctx.send(embed=pep_embed) - - -def setup(bot: Bot) -> None: - """Load the Utils cog.""" - bot.add_cog(Utils(bot)) diff --git a/bot/exts/__init__.py b/bot/exts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bot/exts/alias.py b/bot/exts/alias.py new file mode 100644 index 000000000..77867b933 --- /dev/null +++ b/bot/exts/alias.py @@ -0,0 +1,153 @@ +import inspect +import logging + +from discord import Colour, Embed +from discord.ext.commands import ( + Cog, Command, Context, Greedy, + clean_content, command, group, +) + +from bot.bot import Bot +from bot.converters import FetchedMember, TagNameConverter +from bot.exts.utils.extensions import Extension +from bot.pagination import LinePaginator + +log = logging.getLogger(__name__) + + +class Alias (Cog): + """Aliases for commonly used commands.""" + + def __init__(self, bot: Bot): + self.bot = bot + + async def invoke(self, ctx: Context, cmd_name: str, *args, **kwargs) -> None: + """Invokes a command with args and kwargs.""" + log.debug(f"{cmd_name} was invoked through an alias") + cmd = self.bot.get_command(cmd_name) + if not cmd: + return log.info(f'Did not find command "{cmd_name}" to invoke.') + elif not await cmd.can_run(ctx): + return log.info( + f'{str(ctx.author)} tried to run the command "{cmd_name}" but lacks permission.' + ) + + await ctx.invoke(cmd, *args, **kwargs) + + @command(name='aliases') + async def aliases_command(self, ctx: Context) -> None: + """Show configured aliases on the bot.""" + embed = Embed( + title='Configured aliases', + colour=Colour.blue() + ) + await LinePaginator.paginate( + ( + f"• `{ctx.prefix}{value.name}` " + f"=> `{ctx.prefix}{name[:-len('_alias')].replace('_', ' ')}`" + for name, value in inspect.getmembers(self) + if isinstance(value, Command) and name.endswith('_alias') + ), + ctx, embed, empty=False, max_lines=20 + ) + + @command(name="resources", aliases=("resource",), hidden=True) + async def site_resources_alias(self, ctx: Context) -> None: + """Alias for invoking site resources.""" + await self.invoke(ctx, "site resources") + + @command(name="tools", hidden=True) + async def site_tools_alias(self, ctx: Context) -> None: + """Alias for invoking site tools.""" + await self.invoke(ctx, "site tools") + + @command(name="watch", hidden=True) + async def bigbrother_watch_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + """Alias for invoking bigbrother watch [user] [reason].""" + await self.invoke(ctx, "bigbrother watch", user, reason=reason) + + @command(name="unwatch", hidden=True) + async def bigbrother_unwatch_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + """Alias for invoking bigbrother unwatch [user] [reason].""" + await self.invoke(ctx, "bigbrother unwatch", user, reason=reason) + + @command(name="home", hidden=True) + async def site_home_alias(self, ctx: Context) -> None: + """Alias for invoking site home.""" + await self.invoke(ctx, "site home") + + @command(name="faq", hidden=True) + async def site_faq_alias(self, ctx: Context) -> None: + """Alias for invoking site faq.""" + await self.invoke(ctx, "site faq") + + @command(name="rules", aliases=("rule",), hidden=True) + async def site_rules_alias(self, ctx: Context, rules: Greedy[int], *_: str) -> None: + """Alias for invoking site rules.""" + await self.invoke(ctx, "site rules", *rules) + + @command(name="reload", hidden=True) + async def extensions_reload_alias(self, ctx: Context, *extensions: Extension) -> None: + """Alias for invoking extensions reload [extensions...].""" + await self.invoke(ctx, "extensions reload", *extensions) + + @command(name="defon", hidden=True) + async def defcon_enable_alias(self, ctx: Context) -> None: + """Alias for invoking defcon enable.""" + await self.invoke(ctx, "defcon enable") + + @command(name="defoff", hidden=True) + async def defcon_disable_alias(self, ctx: Context) -> None: + """Alias for invoking defcon disable.""" + await self.invoke(ctx, "defcon disable") + + @command(name="exception", hidden=True) + async def tags_get_traceback_alias(self, ctx: Context) -> None: + """Alias for invoking tags get traceback.""" + await self.invoke(ctx, "tags get", tag_name="traceback") + + @group(name="get", + aliases=("show", "g"), + hidden=True, + invoke_without_command=True) + async def get_group_alias(self, ctx: Context) -> None: + """Group for reverse aliases for commands like `tags get`, allowing for `get tags` or `get docs`.""" + pass + + @get_group_alias.command(name="tags", aliases=("tag", "t"), hidden=True) + async def tags_get_alias( + self, ctx: Context, *, tag_name: TagNameConverter = None + ) -> None: + """ + Alias for invoking tags get [tag_name]. + + tag_name: str - tag to be viewed. + """ + await self.invoke(ctx, "tags get", tag_name=tag_name) + + @get_group_alias.command(name="docs", aliases=("doc", "d"), hidden=True) + async def docs_get_alias( + self, ctx: Context, symbol: clean_content = None + ) -> None: + """Alias for invoking docs get [symbol].""" + await self.invoke(ctx, "docs get", symbol) + + @command(name="nominate", hidden=True) + async def nomination_add_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + """Alias for invoking talentpool add [user] [reason].""" + await self.invoke(ctx, "talentpool add", user, reason=reason) + + @command(name="unnominate", hidden=True) + async def nomination_end_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + """Alias for invoking nomination end [user] [reason].""" + await self.invoke(ctx, "nomination end", user, reason=reason) + + @command(name="nominees", hidden=True) + async def nominees_alias(self, ctx: Context) -> None: + """Alias for invoking tp watched.""" + await self.invoke(ctx, "talentpool watched") + + +def setup(bot: Bot) -> None: + """Load the Alias cog.""" + bot.add_cog(Alias(bot)) diff --git a/bot/exts/backend/__init__.py b/bot/exts/backend/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bot/exts/backend/config_verifier.py b/bot/exts/backend/config_verifier.py new file mode 100644 index 000000000..d72c6c22e --- /dev/null +++ b/bot/exts/backend/config_verifier.py @@ -0,0 +1,40 @@ +import logging + +from discord.ext.commands import Cog + +from bot import constants +from bot.bot import Bot + + +log = logging.getLogger(__name__) + + +class ConfigVerifier(Cog): + """Verify config on startup.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.channel_verify_task = self.bot.loop.create_task(self.verify_channels()) + + async def verify_channels(self) -> None: + """ + Verify channels. + + If any channels in config aren't present in server, log them in a warning. + """ + await self.bot.wait_until_guild_available() + server = self.bot.get_guild(constants.Guild.id) + + server_channel_ids = {channel.id for channel in server.channels} + invalid_channels = [ + channel_name for channel_name, channel_id in constants.Channels + if channel_id not in server_channel_ids + ] + + if invalid_channels: + log.warning(f"Configured channels do not exist in server: {', '.join(invalid_channels)}.") + + +def setup(bot: Bot) -> None: + """Load the ConfigVerifier cog.""" + bot.add_cog(ConfigVerifier(bot)) diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py new file mode 100644 index 000000000..f9d4de638 --- /dev/null +++ b/bot/exts/backend/error_handler.py @@ -0,0 +1,287 @@ +import contextlib +import logging +import typing as t + +from discord import Embed +from discord.ext.commands import Cog, Context, errors +from sentry_sdk import push_scope + +from bot.api import ResponseCodeError +from bot.bot import Bot +from bot.constants import Channels, Colours +from bot.converters import TagNameConverter +from bot.utils.checks import InWhitelistCheckFailure + +log = logging.getLogger(__name__) + + +class ErrorHandler(Cog): + """Handles errors emitted from commands.""" + + def __init__(self, bot: Bot): + self.bot = bot + + def _get_error_embed(self, title: str, body: str) -> Embed: + """Return an embed that contains the exception.""" + return Embed( + title=title, + colour=Colours.soft_red, + description=body + ) + + @Cog.listener() + async def on_command_error(self, ctx: Context, e: errors.CommandError) -> None: + """ + Provide generic command error handling. + + Error handling is deferred to any local error handler, if present. This is done by + checking for the presence of a `handled` attribute on the error. + + Error handling emits a single error message in the invoking context `ctx` and a log message, + prioritised as follows: + + 1. If the name fails to match a command: + * If it matches shh+ or unshh+, the channel is silenced or unsilenced respectively. + Otherwise if it matches a tag, the tag is invoked + * If CommandNotFound is raised when invoking the tag (determined by the presence of the + `invoked_from_error_handler` attribute), this error is treated as being unexpected + and therefore sends an error message + * Commands in the verification channel are ignored + 2. UserInputError: see `handle_user_input_error` + 3. CheckFailure: see `handle_check_failure` + 4. CommandOnCooldown: send an error message in the invoking context + 5. ResponseCodeError: see `handle_api_error` + 6. Otherwise, if not a DisabledCommand, handling is deferred to `handle_unexpected_error` + """ + command = ctx.command + + if hasattr(e, "handled"): + 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 await self.try_silence(ctx): + return + if ctx.channel.id != Channels.verification: + # Try to look for a tag with the command's name + await self.try_get_tag(ctx) + return # Exit early to avoid logging. + elif isinstance(e, errors.UserInputError): + await self.handle_user_input_error(ctx, e) + elif isinstance(e, errors.CheckFailure): + await self.handle_check_failure(ctx, e) + elif isinstance(e, errors.CommandOnCooldown): + await ctx.send(e) + elif isinstance(e, errors.CommandInvokeError): + if isinstance(e.original, ResponseCodeError): + await self.handle_api_error(ctx, e.original) + else: + await self.handle_unexpected_error(ctx, e.original) + return # Exit early to avoid logging. + elif not isinstance(e, errors.DisabledCommand): + # ConversionError, MaxConcurrencyReached, ExtensionError + await self.handle_unexpected_error(ctx, e) + return # Exit early to avoid logging. + + log.debug( + f"Command {command} invoked by {ctx.message.author} with error " + f"{e.__class__.__name__}: {e}" + ) + + @staticmethod + def get_help_command(ctx: Context) -> t.Coroutine: + """Return a prepared `help` command invocation coroutine.""" + if ctx.command: + return ctx.send_help(ctx.command) + + return ctx.send_help() + + async def try_silence(self, ctx: Context) -> bool: + """ + Attempt to invoke the silence or unsilence command if invoke with matches a pattern. + + Respecting the checks if: + * invoked with `shh+` silence channel for amount of h's*2 with max of 15. + * invoked with `unshh+` unsilence channel + Return bool depending on success of command. + """ + command = ctx.invoked_with.lower() + silence_command = self.bot.get_command("silence") + ctx.invoked_from_error_handler = True + try: + if not await silence_command.can_run(ctx): + log.debug("Cancelling attempt to invoke silence/unsilence due to failed checks.") + return False + except errors.CommandError: + log.debug("Cancelling attempt to invoke silence/unsilence due to failed checks.") + return False + if command.startswith("shh"): + await ctx.invoke(silence_command, duration=min(command.count("h")*2, 15)) + return True + elif command.startswith("unshh"): + await ctx.invoke(self.bot.get_command("unsilence")) + return True + return False + + async def try_get_tag(self, ctx: Context) -> None: + """ + Attempt to display a tag by interpreting the command name as a tag name. + + The invocation of tags get respects its checks. Any CommandErrors raised will be handled + by `on_command_error`, but the `invoked_from_error_handler` attribute will be added to + the context to prevent infinite recursion in the case of a CommandNotFound exception. + """ + tags_get_command = self.bot.get_command("tags get") + ctx.invoked_from_error_handler = True + + log_msg = "Cancelling attempt to fall back to a tag due to failed checks." + try: + if not await tags_get_command.can_run(ctx): + log.debug(log_msg) + return + except errors.CommandError as tag_error: + log.debug(log_msg) + await self.on_command_error(ctx, tag_error) + return + + try: + tag_name = await TagNameConverter.convert(ctx, ctx.invoked_with) + except errors.BadArgument: + log.debug( + f"{ctx.author} tried to use an invalid command " + f"and the fallback tag failed validation in TagNameConverter." + ) + else: + with contextlib.suppress(ResponseCodeError): + await ctx.invoke(tags_get_command, tag_name=tag_name) + # Return to not raise the exception + return + + async def handle_user_input_error(self, ctx: Context, e: errors.UserInputError) -> None: + """ + Send an error message in `ctx` for UserInputError, sometimes invoking the help command too. + + * MissingRequiredArgument: send an error message with arg name and the help command + * TooManyArguments: send an error message and the help command + * BadArgument: send an error message and the help command + * BadUnionArgument: send an error message including the error produced by the last converter + * 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): + embed = self._get_error_embed("Missing required argument", e.param.name) + await ctx.send(embed=embed) + await prepared_help_command + self.bot.stats.incr("errors.missing_required_argument") + elif isinstance(e, errors.TooManyArguments): + embed = self._get_error_embed("Too many arguments", str(e)) + await ctx.send(embed=embed) + await prepared_help_command + self.bot.stats.incr("errors.too_many_arguments") + elif isinstance(e, errors.BadArgument): + embed = self._get_error_embed("Bad argument", str(e)) + await ctx.send(embed=embed) + await prepared_help_command + self.bot.stats.incr("errors.bad_argument") + elif isinstance(e, errors.BadUnionArgument): + embed = self._get_error_embed("Bad argument", f"{e}\n{e.errors[-1]}") + await ctx.send(embed=embed) + 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) + self.bot.stats.incr("errors.argument_parsing_error") + else: + embed = self._get_error_embed( + "Input error", + "Something about your input seems off. Check the arguments and try again." + ) + await ctx.send(embed=embed) + await prepared_help_command + self.bot.stats.incr("errors.other_user_input_error") + + @staticmethod + async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None: + """ + Send an error message in `ctx` for certain types of CheckFailure. + + The following types are handled: + + * BotMissingPermissions + * BotMissingRole + * BotMissingAnyRole + * NoPrivateMessage + * InWhitelistCheckFailure + """ + bot_missing_errors = ( + errors.BotMissingPermissions, + errors.BotMissingRole, + errors.BotMissingAnyRole + ) + + if isinstance(e, bot_missing_errors): + ctx.bot.stats.incr("errors.bot_permission_error") + await ctx.send( + "Sorry, it looks like I don't have the permissions or roles I need to do that." + ) + elif isinstance(e, (InWhitelistCheckFailure, errors.NoPrivateMessage)): + ctx.bot.stats.incr("errors.wrong_channel_or_dm_error") + await ctx.send(e) + + @staticmethod + async def handle_api_error(ctx: Context, e: ResponseCodeError) -> None: + """Send an error message in `ctx` for ResponseCodeError and log it.""" + if e.status == 404: + await ctx.send("There does not seem to be anything matching your query.") + log.debug(f"API responded with 404 for command {ctx.command}") + ctx.bot.stats.incr("errors.api_error_404") + elif e.status == 400: + content = await e.response.json() + log.debug(f"API responded with 400 for command {ctx.command}: %r.", content) + await ctx.send("According to the API, your request is malformed.") + ctx.bot.stats.incr("errors.api_error_400") + elif 500 <= e.status < 600: + await ctx.send("Sorry, there seems to be an internal issue with the API.") + log.warning(f"API responded with {e.status} for command {ctx.command}") + ctx.bot.stats.incr("errors.api_internal_server_error") + else: + await ctx.send(f"Got an unexpected status code from the API (`{e.status}`).") + log.warning(f"Unexpected API response for command {ctx.command}: {e.status}") + ctx.bot.stats.incr(f"errors.api_error_{e.status}") + + @staticmethod + async def handle_unexpected_error(ctx: Context, e: errors.CommandError) -> None: + """Send a generic error message in `ctx` and log the exception as an error with exc_info.""" + await ctx.send( + f"Sorry, an unexpected error occurred. Please let us know!\n\n" + f"```{e.__class__.__name__}: {e}```" + ) + + ctx.bot.stats.incr("errors.unexpected") + + with push_scope() as scope: + scope.user = { + "id": ctx.author.id, + "username": str(ctx.author) + } + + scope.set_tag("command", ctx.command.qualified_name) + scope.set_tag("message_id", ctx.message.id) + scope.set_tag("channel_id", ctx.channel.id) + + scope.set_extra("full_message", ctx.message.content) + + if ctx.guild is not None: + scope.set_extra( + "jump_to", + f"https://discordapp.com/channels/{ctx.guild.id}/{ctx.channel.id}/{ctx.message.id}" + ) + + log.error(f"Error executing command invoked by {ctx.message.author}: {ctx.message.content}", exc_info=e) + + +def setup(bot: Bot) -> None: + """Load the ErrorHandler cog.""" + bot.add_cog(ErrorHandler(bot)) diff --git a/bot/exts/backend/logging.py b/bot/exts/backend/logging.py new file mode 100644 index 000000000..94fa2b139 --- /dev/null +++ b/bot/exts/backend/logging.py @@ -0,0 +1,42 @@ +import logging + +from discord import Embed +from discord.ext.commands import Cog + +from bot.bot import Bot +from bot.constants import Channels, DEBUG_MODE + + +log = logging.getLogger(__name__) + + +class Logging(Cog): + """Debug logging module.""" + + def __init__(self, bot: Bot): + self.bot = bot + + self.bot.loop.create_task(self.startup_greeting()) + + async def startup_greeting(self) -> None: + """Announce our presence to the configured devlog channel.""" + await self.bot.wait_until_guild_available() + log.info("Bot connected!") + + embed = Embed(description="Connected!") + embed.set_author( + name="Python Bot", + url="https://github.com/python-discord/bot", + icon_url=( + "https://raw.githubusercontent.com/" + "python-discord/branding/master/logos/logo_circle/logo_circle_large.png" + ) + ) + + if not DEBUG_MODE: + await self.bot.get_channel(Channels.dev_log).send(embed=embed) + + +def setup(bot: Bot) -> None: + """Load the Logging cog.""" + bot.add_cog(Logging(bot)) diff --git a/bot/exts/backend/sync/__init__.py b/bot/exts/backend/sync/__init__.py new file mode 100644 index 000000000..2541beaa8 --- /dev/null +++ b/bot/exts/backend/sync/__init__.py @@ -0,0 +1,7 @@ +from bot.bot import Bot + + +def setup(bot: Bot) -> None: + """Load the Sync cog.""" + from ._cog import Sync + bot.add_cog(Sync(bot)) diff --git a/bot/exts/backend/sync/_cog.py b/bot/exts/backend/sync/_cog.py new file mode 100644 index 000000000..b6068f328 --- /dev/null +++ b/bot/exts/backend/sync/_cog.py @@ -0,0 +1,180 @@ +import logging +from typing import Any, Dict + +from discord import Member, Role, User +from discord.ext import commands +from discord.ext.commands import Cog, Context + +from bot import constants +from bot.api import ResponseCodeError +from bot.bot import Bot +from . import _syncers + +log = logging.getLogger(__name__) + + +class Sync(Cog): + """Captures relevant events and sends them to the site.""" + + def __init__(self, bot: Bot) -> None: + self.bot = bot + self.role_syncer = _syncers.RoleSyncer(self.bot) + self.user_syncer = _syncers.UserSyncer(self.bot) + + self.bot.loop.create_task(self.sync_guild()) + + async def sync_guild(self) -> None: + """Syncs the roles/users of the guild with the database.""" + await self.bot.wait_until_guild_available() + + guild = self.bot.get_guild(constants.Guild.id) + if guild is None: + return + + for syncer in (self.role_syncer, self.user_syncer): + await syncer.sync(guild) + + async def patch_user(self, user_id: int, json: Dict[str, Any], ignore_404: bool = False) -> None: + """Send a PATCH request to partially update a user in the database.""" + try: + await self.bot.api_client.patch(f"bot/users/{user_id}", json=json) + except ResponseCodeError as e: + if e.response.status != 404: + raise + if not ignore_404: + log.warning("Unable to update user, got 404. Assuming race condition from join event.") + + @Cog.listener() + async def on_guild_role_create(self, role: Role) -> None: + """Adds newly create role to the database table over the API.""" + if role.guild.id != constants.Guild.id: + return + + await self.bot.api_client.post( + 'bot/roles', + json={ + 'colour': role.colour.value, + 'id': role.id, + 'name': role.name, + 'permissions': role.permissions.value, + 'position': role.position, + } + ) + + @Cog.listener() + async def on_guild_role_delete(self, role: Role) -> None: + """Deletes role from the database when it's deleted from the guild.""" + if role.guild.id != constants.Guild.id: + return + + await self.bot.api_client.delete(f'bot/roles/{role.id}') + + @Cog.listener() + async def on_guild_role_update(self, before: Role, after: Role) -> None: + """Syncs role with the database if any of the stored attributes were updated.""" + if after.guild.id != constants.Guild.id: + return + + was_updated = ( + before.name != after.name + or before.colour != after.colour + or before.permissions != after.permissions + or before.position != after.position + ) + + if was_updated: + await self.bot.api_client.put( + f'bot/roles/{after.id}', + json={ + 'colour': after.colour.value, + 'id': after.id, + 'name': after.name, + 'permissions': after.permissions.value, + 'position': after.position, + } + ) + + @Cog.listener() + async def on_member_join(self, member: Member) -> None: + """ + Adds a new user or updates existing user to the database when a member joins the guild. + + If the joining member is a user that is already known to the database (i.e., a user that + previously left), it will update the user's information. If the user is not yet known by + the database, the user is added. + """ + if member.guild.id != constants.Guild.id: + return + + packed = { + 'discriminator': int(member.discriminator), + 'id': member.id, + 'in_guild': True, + 'name': member.name, + 'roles': sorted(role.id for role in member.roles) + } + + got_error = False + + try: + # First try an update of the user to set the `in_guild` field and other + # fields that may have changed since the last time we've seen them. + await self.bot.api_client.put(f'bot/users/{member.id}', json=packed) + + except ResponseCodeError as e: + # If we didn't get 404, something else broke - propagate it up. + if e.response.status != 404: + raise + + got_error = True # yikes + + if got_error: + # If we got `404`, the user is new. Create them. + await self.bot.api_client.post('bot/users', json=packed) + + @Cog.listener() + async def on_member_remove(self, member: Member) -> None: + """Set the in_guild field to False when a member leaves the guild.""" + if member.guild.id != constants.Guild.id: + return + + await self.patch_user(member.id, json={"in_guild": False}) + + @Cog.listener() + async def on_member_update(self, before: Member, after: Member) -> None: + """Update the roles of the member in the database if a change is detected.""" + if after.guild.id != constants.Guild.id: + return + + if before.roles != after.roles: + updated_information = {"roles": sorted(role.id for role in after.roles)} + await self.patch_user(after.id, json=updated_information) + + @Cog.listener() + async def on_user_update(self, before: User, after: User) -> None: + """Update the user information in the database if a relevant change is detected.""" + attrs = ("name", "discriminator") + if any(getattr(before, attr) != getattr(after, attr) for attr in attrs): + updated_information = { + "name": after.name, + "discriminator": int(after.discriminator), + } + # A 404 likely means the user is in another guild. + await self.patch_user(after.id, json=updated_information, ignore_404=True) + + @commands.group(name='sync') + @commands.has_permissions(administrator=True) + async def sync_group(self, ctx: Context) -> None: + """Run synchronizations between the bot and site manually.""" + + @sync_group.command(name='roles') + @commands.has_permissions(administrator=True) + async def sync_roles_command(self, ctx: Context) -> None: + """Manually synchronise the guild's roles with the roles on the site.""" + await self.role_syncer.sync(ctx.guild, ctx) + + @sync_group.command(name='users') + @commands.has_permissions(administrator=True) + async def sync_users_command(self, ctx: Context) -> None: + """Manually synchronise the guild's users with the users on the site.""" + await self.user_syncer.sync(ctx.guild, ctx) diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py new file mode 100644 index 000000000..f7ba811bc --- /dev/null +++ b/bot/exts/backend/sync/_syncers.py @@ -0,0 +1,347 @@ +import abc +import asyncio +import logging +import typing as t +from collections import namedtuple +from functools import partial + +import discord +from discord import Guild, HTTPException, Member, Message, Reaction, User +from discord.ext.commands import Context + +from bot import constants +from bot.api import ResponseCodeError +from bot.bot import Bot + +log = logging.getLogger(__name__) + +# These objects are declared as namedtuples because tuples are hashable, +# something that we make use of when diffing site roles against guild roles. +_Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions', 'position')) +_User = namedtuple('User', ('id', 'name', 'discriminator', 'roles', 'in_guild')) +_Diff = namedtuple('Diff', ('created', 'updated', 'deleted')) + + +class Syncer(abc.ABC): + """Base class for synchronising the database with objects in the Discord cache.""" + + _CORE_DEV_MENTION = f"<@&{constants.Roles.core_developers}> " + _REACTION_EMOJIS = (constants.Emojis.check_mark, constants.Emojis.cross_mark) + + def __init__(self, bot: Bot) -> None: + self.bot = bot + + @property + @abc.abstractmethod + def name(self) -> str: + """The name of the syncer; used in output messages and logging.""" + raise NotImplementedError # pragma: no cover + + async def _send_prompt(self, message: t.Optional[Message] = None) -> t.Optional[Message]: + """ + Send a prompt to confirm or abort a sync using reactions and return the sent message. + + If a message is given, it is edited to display the prompt and reactions. Otherwise, a new + message is sent to the dev-core channel and mentions the core developers role. If the + channel cannot be retrieved, return None. + """ + log.trace(f"Sending {self.name} sync confirmation prompt.") + + msg_content = ( + f'Possible cache issue while syncing {self.name}s. ' + f'More than {constants.Sync.max_diff} {self.name}s were changed. ' + f'React to confirm or abort the sync.' + ) + + # Send to core developers if it's an automatic sync. + if not message: + log.trace("Message not provided for confirmation; creating a new one in dev-core.") + channel = self.bot.get_channel(constants.Channels.dev_core) + + if not channel: + log.debug("Failed to get the dev-core channel from cache; attempting to fetch it.") + try: + channel = await self.bot.fetch_channel(constants.Channels.dev_core) + except HTTPException: + log.exception( + f"Failed to fetch channel for sending sync confirmation prompt; " + f"aborting {self.name} sync." + ) + return None + + allowed_roles = [discord.Object(constants.Roles.core_developers)] + message = await channel.send( + f"{self._CORE_DEV_MENTION}{msg_content}", + allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) + ) + else: + await message.edit(content=msg_content) + + # Add the initial reactions. + log.trace(f"Adding reactions to {self.name} syncer confirmation prompt.") + for emoji in self._REACTION_EMOJIS: + await message.add_reaction(emoji) + + return message + + def _reaction_check( + self, + author: Member, + message: Message, + reaction: Reaction, + user: t.Union[Member, User] + ) -> bool: + """ + Return True if the `reaction` is a valid confirmation or abort reaction on `message`. + + If the `author` of the prompt is a bot, then a reaction by any core developer will be + considered valid. Otherwise, the author of the reaction (`user`) will have to be the + `author` of the prompt. + """ + # For automatic syncs, check for the core dev role instead of an exact author + has_role = any(constants.Roles.core_developers == role.id for role in user.roles) + return ( + reaction.message.id == message.id + and not user.bot + and (has_role if author.bot else user == author) + and str(reaction.emoji) in self._REACTION_EMOJIS + ) + + async def _wait_for_confirmation(self, author: Member, message: Message) -> bool: + """ + Wait for a confirmation reaction by `author` on `message` and return True if confirmed. + + Uses the `_reaction_check` function to determine if a reaction is valid. + + If there is no reaction within `bot.constants.Sync.confirm_timeout` seconds, return False. + To acknowledge the reaction (or lack thereof), `message` will be edited. + """ + # Preserve the core-dev role mention in the message edits so users aren't confused about + # where notifications came from. + mention = self._CORE_DEV_MENTION if author.bot else "" + + reaction = None + try: + log.trace(f"Waiting for a reaction to the {self.name} syncer confirmation prompt.") + reaction, _ = await self.bot.wait_for( + 'reaction_add', + check=partial(self._reaction_check, author, message), + timeout=constants.Sync.confirm_timeout + ) + except asyncio.TimeoutError: + # reaction will remain none thus sync will be aborted in the finally block below. + log.debug(f"The {self.name} syncer confirmation prompt timed out.") + + if str(reaction) == constants.Emojis.check_mark: + log.trace(f"The {self.name} syncer was confirmed.") + await message.edit(content=f':ok_hand: {mention}{self.name} sync will proceed.') + return True + else: + log.info(f"The {self.name} syncer was aborted or timed out!") + await message.edit( + content=f':warning: {mention}{self.name} sync aborted or timed out!' + ) + return False + + @abc.abstractmethod + async def _get_diff(self, guild: Guild) -> _Diff: + """Return the difference between the cache of `guild` and the database.""" + raise NotImplementedError # pragma: no cover + + @abc.abstractmethod + async def _sync(self, diff: _Diff) -> None: + """Perform the API calls for synchronisation.""" + raise NotImplementedError # pragma: no cover + + async def _get_confirmation_result( + self, + diff_size: int, + author: Member, + message: t.Optional[Message] = None + ) -> t.Tuple[bool, t.Optional[Message]]: + """ + Prompt for confirmation and return a tuple of the result and the prompt message. + + `diff_size` is the size of the diff of the sync. If it is greater than + `bot.constants.Sync.max_diff`, the prompt will be sent. The `author` is the invoked of the + sync and the `message` is an extant message to edit to display the prompt. + + If confirmed or no confirmation was needed, the result is True. The returned message will + either be the given `message` or a new one which was created when sending the prompt. + """ + log.trace(f"Determining if confirmation prompt should be sent for {self.name} syncer.") + if diff_size > constants.Sync.max_diff: + message = await self._send_prompt(message) + if not message: + return False, None # Couldn't get channel. + + confirmed = await self._wait_for_confirmation(author, message) + if not confirmed: + return False, message # Sync aborted. + + return True, message + + async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> None: + """ + Synchronise the database with the cache of `guild`. + + If the differences between the cache and the database are greater than + `bot.constants.Sync.max_diff`, then a confirmation prompt will be sent to the dev-core + channel. The confirmation can be optionally redirect to `ctx` instead. + """ + log.info(f"Starting {self.name} syncer.") + + message = None + author = self.bot.user + if ctx: + message = await ctx.send(f"📊 Synchronising {self.name}s.") + author = ctx.author + + diff = await self._get_diff(guild) + diff_dict = diff._asdict() # Ugly method for transforming the NamedTuple into a dict + totals = {k: len(v) for k, v in diff_dict.items() if v is not None} + diff_size = sum(totals.values()) + + confirmed, message = await self._get_confirmation_result(diff_size, author, message) + if not confirmed: + return + + # Preserve the core-dev role mention in the message edits so users aren't confused about + # where notifications came from. + mention = self._CORE_DEV_MENTION if author.bot else "" + + try: + await self._sync(diff) + except ResponseCodeError as e: + log.exception(f"{self.name} syncer failed!") + + # Don't show response text because it's probably some really long HTML. + results = f"status {e.status}\n```{e.response_json or 'See log output for details'}```" + content = f":x: {mention}Synchronisation of {self.name}s failed: {results}" + else: + results = ", ".join(f"{name} `{total}`" for name, total in totals.items()) + log.info(f"{self.name} syncer finished: {results}.") + content = f":ok_hand: {mention}Synchronisation of {self.name}s complete: {results}" + + if message: + await message.edit(content=content) + + +class RoleSyncer(Syncer): + """Synchronise the database with roles in the cache.""" + + name = "role" + + async def _get_diff(self, guild: Guild) -> _Diff: + """Return the difference of roles between the cache of `guild` and the database.""" + log.trace("Getting the diff for roles.") + roles = await self.bot.api_client.get('bot/roles') + + # Pack DB roles and guild roles into one common, hashable format. + # They're hashable so that they're easily comparable with sets later. + db_roles = {_Role(**role_dict) for role_dict in roles} + guild_roles = { + _Role( + id=role.id, + name=role.name, + colour=role.colour.value, + permissions=role.permissions.value, + position=role.position, + ) + for role in guild.roles + } + + guild_role_ids = {role.id for role in guild_roles} + api_role_ids = {role.id for role in db_roles} + new_role_ids = guild_role_ids - api_role_ids + deleted_role_ids = api_role_ids - guild_role_ids + + # New roles are those which are on the cached guild but not on the + # DB guild, going by the role ID. We need to send them in for creation. + roles_to_create = {role for role in guild_roles if role.id in new_role_ids} + roles_to_update = guild_roles - db_roles - roles_to_create + roles_to_delete = {role for role in db_roles if role.id in deleted_role_ids} + + return _Diff(roles_to_create, roles_to_update, roles_to_delete) + + async def _sync(self, diff: _Diff) -> None: + """Synchronise the database with the role cache of `guild`.""" + log.trace("Syncing created roles...") + for role in diff.created: + await self.bot.api_client.post('bot/roles', json=role._asdict()) + + log.trace("Syncing updated roles...") + for role in diff.updated: + await self.bot.api_client.put(f'bot/roles/{role.id}', json=role._asdict()) + + log.trace("Syncing deleted roles...") + for role in diff.deleted: + await self.bot.api_client.delete(f'bot/roles/{role.id}') + + +class UserSyncer(Syncer): + """Synchronise the database with users in the cache.""" + + name = "user" + + async def _get_diff(self, guild: Guild) -> _Diff: + """Return the difference of users between the cache of `guild` and the database.""" + log.trace("Getting the diff for users.") + users = await self.bot.api_client.get('bot/users') + + # Pack DB roles and guild roles into one common, hashable format. + # They're hashable so that they're easily comparable with sets later. + db_users = { + user_dict['id']: _User( + roles=tuple(sorted(user_dict.pop('roles'))), + **user_dict + ) + for user_dict in users + } + guild_users = { + member.id: _User( + id=member.id, + name=member.name, + discriminator=int(member.discriminator), + roles=tuple(sorted(role.id for role in member.roles)), + in_guild=True + ) + for member in guild.members + } + + users_to_create = set() + users_to_update = set() + + for db_user in db_users.values(): + guild_user = guild_users.get(db_user.id) + if guild_user is not None: + if db_user != guild_user: + users_to_update.add(guild_user) + + elif db_user.in_guild: + # The user is known in the DB but not the guild, and the + # DB currently specifies that the user is a member of the guild. + # This means that the user has left since the last sync. + # Update the `in_guild` attribute of the user on the site + # to signify that the user left. + new_api_user = db_user._replace(in_guild=False) + users_to_update.add(new_api_user) + + new_user_ids = set(guild_users.keys()) - set(db_users.keys()) + for user_id in new_user_ids: + # The user is known on the guild but not on the API. This means + # that the user has joined since the last sync. Create it. + new_user = guild_users[user_id] + users_to_create.add(new_user) + + return _Diff(users_to_create, users_to_update, None) + + async def _sync(self, diff: _Diff) -> None: + """Synchronise the database with the user cache of `guild`.""" + log.trace("Syncing created users...") + for user in diff.created: + await self.bot.api_client.post('bot/users', json=user._asdict()) + + log.trace("Syncing updated users...") + for user in diff.updated: + await self.bot.api_client.put(f'bot/users/{user.id}', json=user._asdict()) diff --git a/bot/exts/dm_relay.py b/bot/exts/dm_relay.py new file mode 100644 index 000000000..0d8f340b4 --- /dev/null +++ b/bot/exts/dm_relay.py @@ -0,0 +1,124 @@ +import logging +from typing import Optional + +import discord +from discord import Color +from discord.ext import commands +from discord.ext.commands import Cog + +from bot import constants +from bot.bot import Bot +from bot.converters import UserMentionOrID +from bot.utils import RedisCache +from bot.utils.checks import in_whitelist_check, with_role_check +from bot.utils.messages import send_attachments +from bot.utils.webhooks import send_webhook + +log = logging.getLogger(__name__) + + +class DMRelay(Cog): + """Relay direct messages to and from the bot.""" + + # RedisCache[str, t.Union[discord.User.id, discord.Member.id]] + dm_cache = RedisCache() + + def __init__(self, bot: Bot): + self.bot = bot + self.webhook_id = constants.Webhooks.dm_log + self.webhook = None + self.bot.loop.create_task(self.fetch_webhook()) + + @commands.command(aliases=("reply",)) + async def send_dm(self, ctx: commands.Context, member: Optional[UserMentionOrID], *, message: str) -> None: + """ + Allows you to send a DM to a user from the bot. + + If `member` is not provided, it will send to the last user who DM'd the bot. + + This feature should be used extremely sparingly. Use ModMail if you need to have a serious + conversation with a user. This is just for responding to extraordinary DMs, having a little + fun with users, and telling people they are DMing the wrong bot. + + NOTE: This feature will be removed if it is overused. + """ + if not member: + user_id = await self.dm_cache.get("last_user") + member = ctx.guild.get_member(user_id) if user_id else None + + # If we still don't have a Member at this point, give up + if not member: + log.debug("This bot has never gotten a DM, or the RedisCache has been cleared.") + await ctx.message.add_reaction("❌") + return + + try: + await member.send(message) + except discord.errors.Forbidden: + log.debug("User has disabled DMs.") + await ctx.message.add_reaction("❌") + else: + await ctx.message.add_reaction("✅") + self.bot.stats.incr("dm_relay.dm_sent") + + async def fetch_webhook(self) -> None: + """Fetches the webhook object, so we can post to it.""" + await self.bot.wait_until_guild_available() + + try: + self.webhook = await self.bot.fetch_webhook(self.webhook_id) + except discord.HTTPException: + log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") + + @Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """Relays the message's content and attachments to the dm_log channel.""" + # Only relay DMs from humans + if message.author.bot or message.guild or self.webhook is None: + return + + if message.clean_content: + await send_webhook( + webhook=self.webhook, + content=message.clean_content, + username=f"{message.author.display_name} ({message.author.id})", + avatar_url=message.author.avatar_url + ) + await self.dm_cache.set("last_user", message.author.id) + self.bot.stats.incr("dm_relay.dm_received") + + # Handle any attachments + if message.attachments: + try: + await send_attachments(message, self.webhook) + except (discord.errors.Forbidden, discord.errors.NotFound): + e = discord.Embed( + description=":x: **This message contained an attachment, but it could not be retrieved**", + color=Color.red() + ) + await send_webhook( + webhook=self.webhook, + embed=e, + username=f"{message.author.display_name} ({message.author.id})", + avatar_url=message.author.avatar_url + ) + except discord.HTTPException: + log.exception("Failed to send an attachment to the webhook") + + def cog_check(self, ctx: commands.Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + checks = [ + with_role_check(ctx, *constants.MODERATION_ROLES), + in_whitelist_check( + ctx, + channels=[constants.Channels.dm_log], + redirect=None, + fail_silently=True, + ) + ] + return all(checks) + + +def setup(bot: Bot) -> None: + """Load the DMRelay cog.""" + bot.add_cog(DMRelay(bot)) diff --git a/bot/exts/duck_pond.py b/bot/exts/duck_pond.py new file mode 100644 index 000000000..7021069fa --- /dev/null +++ b/bot/exts/duck_pond.py @@ -0,0 +1,166 @@ +import logging +from typing import Union + +import discord +from discord import Color, Embed, Member, Message, RawReactionActionEvent, User, errors +from discord.ext.commands import Cog + +from bot import constants +from bot.bot import Bot +from bot.utils.messages import send_attachments +from bot.utils.webhooks import send_webhook + +log = logging.getLogger(__name__) + + +class DuckPond(Cog): + """Relays messages to #duck-pond whenever a certain number of duck reactions have been achieved.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.webhook_id = constants.Webhooks.duck_pond + self.webhook = None + self.bot.loop.create_task(self.fetch_webhook()) + + async def fetch_webhook(self) -> None: + """Fetches the webhook object, so we can post to it.""" + await self.bot.wait_until_guild_available() + + try: + self.webhook = await self.bot.fetch_webhook(self.webhook_id) + except discord.HTTPException: + log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") + + @staticmethod + def is_staff(member: Union[User, Member]) -> bool: + """Check if a specific member or user is staff.""" + if hasattr(member, "roles"): + for role in member.roles: + if role.id in constants.STAFF_ROLES: + return True + return False + + async def has_green_checkmark(self, message: Message) -> bool: + """Check if the message has a green checkmark reaction.""" + for reaction in message.reactions: + if reaction.emoji == "✅": + async for user in reaction.users(): + if user == self.bot.user: + return True + return False + + async def count_ducks(self, message: Message) -> int: + """ + Count the number of ducks in the reactions of a specific message. + + Only counts ducks added by staff members. + """ + duck_count = 0 + duck_reactors = [] + + for reaction in message.reactions: + async for user in reaction.users(): + + # Is the user a staff member and not already counted as reactor? + if not self.is_staff(user) or user.id in duck_reactors: + continue + + # Is the emoji a duck? + if hasattr(reaction.emoji, "id"): + if reaction.emoji.id in constants.DuckPond.custom_emojis: + duck_count += 1 + duck_reactors.append(user.id) + elif isinstance(reaction.emoji, str): + if reaction.emoji == "🦆": + duck_count += 1 + duck_reactors.append(user.id) + return duck_count + + async def relay_message(self, message: Message) -> None: + """Relays the message's content and attachments to the duck pond channel.""" + if message.clean_content: + await send_webhook( + webhook=self.webhook, + content=message.clean_content, + username=message.author.display_name, + avatar_url=message.author.avatar_url + ) + + if message.attachments: + try: + await send_attachments(message, self.webhook) + except (errors.Forbidden, errors.NotFound): + e = Embed( + description=":x: **This message contained an attachment, but it could not be retrieved**", + color=Color.red() + ) + await send_webhook( + webhook=self.webhook, + embed=e, + username=message.author.display_name, + avatar_url=message.author.avatar_url + ) + except discord.HTTPException: + log.exception("Failed to send an attachment to the webhook") + + await message.add_reaction("✅") + + @staticmethod + def _payload_has_duckpond_emoji(payload: RawReactionActionEvent) -> bool: + """Test if the RawReactionActionEvent payload contains a duckpond emoji.""" + if payload.emoji.is_custom_emoji(): + if payload.emoji.id in constants.DuckPond.custom_emojis: + return True + elif payload.emoji.name == "🦆": + return True + + return False + + @Cog.listener() + async def on_raw_reaction_add(self, payload: RawReactionActionEvent) -> None: + """ + Determine if a message should be sent to the duck pond. + + This will count the number of duck reactions on the message, and if this amount meets the + amount of ducks specified in the config under duck_pond/threshold, it will + send the message off to the duck pond. + """ + # Is the emoji in the reaction a duck? + if not self._payload_has_duckpond_emoji(payload): + return + + channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id) + message = await channel.fetch_message(payload.message_id) + member = discord.utils.get(message.guild.members, id=payload.user_id) + + # Is the member a human and a staff member? + if not self.is_staff(member) or member.bot: + return + + # Does the message already have a green checkmark? + if await self.has_green_checkmark(message): + return + + # Time to count our ducks! + duck_count = await self.count_ducks(message) + + # If we've got more than the required amount of ducks, send the message to the duck_pond. + if duck_count >= constants.DuckPond.threshold: + await self.relay_message(message) + + @Cog.listener() + async def on_raw_reaction_remove(self, payload: RawReactionActionEvent) -> None: + """Ensure that people don't remove the green checkmark from duck ponded messages.""" + channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id) + + # Prevent the green checkmark from being removed + if payload.emoji.name == "✅": + message = await channel.fetch_message(payload.message_id) + duck_count = await self.count_ducks(message) + if duck_count >= constants.DuckPond.threshold: + await message.add_reaction("✅") + + +def setup(bot: Bot) -> None: + """Load the DuckPond cog.""" + bot.add_cog(DuckPond(bot)) diff --git a/bot/exts/filters/__init__.py b/bot/exts/filters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py new file mode 100644 index 000000000..c76bd2c60 --- /dev/null +++ b/bot/exts/filters/antimalware.py @@ -0,0 +1,98 @@ +import logging +import typing as t +from os.path import splitext + +from discord import Embed, Message, NotFound +from discord.ext.commands import Cog + +from bot.bot import Bot +from bot.constants import Channels, STAFF_ROLES, URLs + +log = logging.getLogger(__name__) + +PY_EMBED_DESCRIPTION = ( + "It looks like you tried to attach a Python file - " + f"please use a code-pasting service such as {URLs.site_schema}{URLs.site_paste}" +) + +TXT_EMBED_DESCRIPTION = ( + "**Uh-oh!** It looks like your message got zapped by our spam filter. " + "We currently don't allow `.txt` attachments, so here are some tips to help you travel safely: \n\n" + "• If you attempted to send a message longer than 2000 characters, try shortening your message " + "to fit within the character limit or use a pasting service (see below) \n\n" + "• If you tried to show someone your code, you can use codeblocks \n(run `!code-blocks` in " + "{cmd_channel_mention} for more information) or use a pasting service like: " + f"\n\n{URLs.site_schema}{URLs.site_paste}" +) + +DISALLOWED_EMBED_DESCRIPTION = ( + "It looks like you tried to attach file type(s) that we do not allow ({blocked_extensions_str}). " + "We currently allow the following file types: **{joined_whitelist}**.\n\n" + "Feel free to ask in {meta_channel_mention} if you think this is a mistake." +) + + +class AntiMalware(Cog): + """Delete messages which contain attachments with non-whitelisted file extensions.""" + + def __init__(self, bot: Bot): + self.bot = bot + + def _get_whitelisted_file_formats(self) -> list: + """Get the file formats currently on the whitelist.""" + return self.bot.filter_list_cache['FILE_FORMAT.True'].keys() + + def _get_disallowed_extensions(self, message: Message) -> t.Iterable[str]: + """Get an iterable containing all the disallowed extensions of attachments.""" + file_extensions = {splitext(attachment.filename.lower())[1] for attachment in message.attachments} + extensions_blocked = file_extensions - set(self._get_whitelisted_file_formats()) + return extensions_blocked + + @Cog.listener() + async def on_message(self, message: Message) -> None: + """Identify messages with prohibited attachments.""" + # Return when message don't have attachment and don't moderate DMs + if not message.attachments or not message.guild: + return + + # Check if user is staff, if is, return + # Since we only care that roles exist to iterate over, check for the attr rather than a User/Member instance + if hasattr(message.author, "roles") and any(role.id in STAFF_ROLES for role in message.author.roles): + return + + embed = Embed() + extensions_blocked = self._get_disallowed_extensions(message) + blocked_extensions_str = ', '.join(extensions_blocked) + if ".py" in extensions_blocked: + # Short-circuit on *.py files to provide a pastebin link + embed.description = PY_EMBED_DESCRIPTION + elif ".txt" in extensions_blocked: + # Work around Discord AutoConversion of messages longer than 2000 chars to .txt + cmd_channel = self.bot.get_channel(Channels.bot_commands) + embed.description = TXT_EMBED_DESCRIPTION.format(cmd_channel_mention=cmd_channel.mention) + elif extensions_blocked: + meta_channel = self.bot.get_channel(Channels.meta) + embed.description = DISALLOWED_EMBED_DESCRIPTION.format( + joined_whitelist=', '.join(self._get_whitelisted_file_formats()), + blocked_extensions_str=blocked_extensions_str, + meta_channel_mention=meta_channel.mention, + ) + + if embed.description: + log.info( + f"User '{message.author}' ({message.author.id}) uploaded blacklisted file(s): {blocked_extensions_str}", + extra={"attachment_list": [attachment.filename for attachment in message.attachments]} + ) + + await message.channel.send(f"Hey {message.author.mention}!", embed=embed) + + # Delete the offending message: + try: + await message.delete() + except NotFound: + log.info(f"Tried to delete message `{message.id}`, but message could not be found.") + + +def setup(bot: Bot) -> None: + """Load the AntiMalware cog.""" + bot.add_cog(AntiMalware(bot)) diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py new file mode 100644 index 000000000..3c5f13ebf --- /dev/null +++ b/bot/exts/filters/antispam.py @@ -0,0 +1,288 @@ +import asyncio +import logging +from collections.abc import Mapping +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from operator import itemgetter +from typing import Dict, Iterable, List, Set + +from discord import Colour, Member, Message, NotFound, Object, TextChannel +from discord.ext.commands import Cog + +from bot import rules +from bot.bot import Bot +from bot.constants import ( + AntiSpam as AntiSpamConfig, Channels, + Colours, DEBUG_MODE, Event, Filter, + Guild as GuildConfig, Icons, + STAFF_ROLES, +) +from bot.converters import Duration +from bot.exts.moderation.modlog import ModLog +from bot.utils.messages import send_attachments + + +log = logging.getLogger(__name__) + +RULE_FUNCTION_MAPPING = { + 'attachments': rules.apply_attachments, + 'burst': rules.apply_burst, + 'burst_shared': rules.apply_burst_shared, + 'chars': rules.apply_chars, + 'discord_emojis': rules.apply_discord_emojis, + 'duplicates': rules.apply_duplicates, + 'links': rules.apply_links, + 'mentions': rules.apply_mentions, + 'newlines': rules.apply_newlines, + 'role_mentions': rules.apply_role_mentions +} + + +@dataclass +class DeletionContext: + """Represents a Deletion Context for a single spam event.""" + + channel: TextChannel + members: Dict[int, Member] = field(default_factory=dict) + rules: Set[str] = field(default_factory=set) + messages: Dict[int, Message] = field(default_factory=dict) + attachments: List[List[str]] = field(default_factory=list) + + async def add(self, rule_name: str, members: Iterable[Member], messages: Iterable[Message]) -> None: + """Adds new rule violation events to the deletion context.""" + self.rules.add(rule_name) + + for member in members: + if member.id not in self.members: + self.members[member.id] = member + + for message in messages: + if message.id not in self.messages: + self.messages[message.id] = message + + # Re-upload attachments + destination = message.guild.get_channel(Channels.attachment_log) + urls = await send_attachments(message, destination, link_large=False) + self.attachments.append(urls) + + async def upload_messages(self, actor_id: int, modlog: ModLog) -> None: + """Method that takes care of uploading the queue and posting modlog alert.""" + triggered_by_users = ", ".join(f"{m} (`{m.id}`)" for m in self.members.values()) + + mod_alert_message = ( + f"**Triggered by:** {triggered_by_users}\n" + f"**Channel:** {self.channel.mention}\n" + f"**Rules:** {', '.join(rule for rule in self.rules)}\n" + ) + + # For multiple messages or those with excessive newlines, use the logs API + if len(self.messages) > 1 or 'newlines' in self.rules: + url = await modlog.upload_log(self.messages.values(), actor_id, self.attachments) + mod_alert_message += f"A complete log of the offending messages can be found [here]({url})" + else: + mod_alert_message += "Message:\n" + [message] = self.messages.values() + content = message.clean_content + remaining_chars = 2040 - len(mod_alert_message) + + if len(content) > remaining_chars: + content = content[:remaining_chars] + "..." + + mod_alert_message += f"{content}" + + *_, last_message = self.messages.values() + await modlog.send_log_message( + icon_url=Icons.filtering, + colour=Colour(Colours.soft_red), + title="Spam detected!", + text=mod_alert_message, + thumbnail=last_message.author.avatar_url_as(static_format="png"), + channel_id=Channels.mod_alerts, + ping_everyone=AntiSpamConfig.ping_everyone + ) + + +class AntiSpam(Cog): + """Cog that controls our anti-spam measures.""" + + def __init__(self, bot: Bot, validation_errors: Dict[str, str]) -> None: + self.bot = bot + self.validation_errors = validation_errors + role_id = AntiSpamConfig.punishment['role_id'] + self.muted_role = Object(role_id) + self.expiration_date_converter = Duration() + + self.message_deletion_queue = dict() + + self.bot.loop.create_task(self.alert_on_validation_error()) + + @property + def mod_log(self) -> ModLog: + """Allows for easy access of the ModLog cog.""" + return self.bot.get_cog("ModLog") + + async def alert_on_validation_error(self) -> None: + """Unloads the cog and alerts admins if configuration validation failed.""" + await self.bot.wait_until_guild_available() + if self.validation_errors: + body = "**The following errors were encountered:**\n" + body += "\n".join(f"- {error}" for error in self.validation_errors.values()) + body += "\n\n**The cog has been unloaded.**" + + await self.mod_log.send_log_message( + title="Error: AntiSpam configuration validation failed!", + text=body, + ping_everyone=True, + icon_url=Icons.token_removed, + colour=Colour.red() + ) + + self.bot.remove_cog(self.__class__.__name__) + return + + @Cog.listener() + async def on_message(self, message: Message) -> None: + """Applies the antispam rules to each received message.""" + if ( + not message.guild + or message.guild.id != GuildConfig.id + or message.author.bot + or (message.channel.id in Filter.channel_whitelist and not DEBUG_MODE) + or (any(role.id in STAFF_ROLES for role in message.author.roles) and not DEBUG_MODE) + ): + return + + # Fetch the rule configuration with the highest rule interval. + max_interval_config = max( + AntiSpamConfig.rules.values(), + key=itemgetter('interval') + ) + max_interval = max_interval_config['interval'] + + # Store history messages since `interval` seconds ago in a list to prevent unnecessary API calls. + earliest_relevant_at = datetime.utcnow() - timedelta(seconds=max_interval) + relevant_messages = [ + msg async for msg in message.channel.history(after=earliest_relevant_at, oldest_first=False) + if not msg.author.bot + ] + + for rule_name in AntiSpamConfig.rules: + rule_config = AntiSpamConfig.rules[rule_name] + rule_function = RULE_FUNCTION_MAPPING[rule_name] + + # Create a list of messages that were sent in the interval that the rule cares about. + latest_interesting_stamp = datetime.utcnow() - timedelta(seconds=rule_config['interval']) + messages_for_rule = [ + msg for msg in relevant_messages if msg.created_at > latest_interesting_stamp + ] + result = await rule_function(message, messages_for_rule, rule_config) + + # If the rule returns `None`, that means the message didn't violate it. + # If it doesn't, it returns a tuple in the form `(str, Iterable[discord.Member])` + # which contains the reason for why the message violated the rule and + # an iterable of all members that violated the rule. + if result is not None: + self.bot.stats.incr(f"mod_alerts.{rule_name}") + reason, members, relevant_messages = result + full_reason = f"`{rule_name}` rule: {reason}" + + # If there's no spam event going on for this channel, start a new Message Deletion Context + channel = message.channel + if channel.id not in self.message_deletion_queue: + log.trace(f"Creating queue for channel `{channel.id}`") + self.message_deletion_queue[message.channel.id] = DeletionContext(channel) + self.bot.loop.create_task(self._process_deletion_context(message.channel.id)) + + # Add the relevant of this trigger to the Deletion Context + await self.message_deletion_queue[message.channel.id].add( + rule_name=rule_name, + members=members, + messages=relevant_messages + ) + + for member in members: + + # Fire it off as a background task to ensure + # that the sleep doesn't block further tasks + self.bot.loop.create_task( + self.punish(message, member, full_reason) + ) + + await self.maybe_delete_messages(channel, relevant_messages) + break + + async def punish(self, msg: Message, member: Member, reason: str) -> None: + """Punishes the given member for triggering an antispam rule.""" + if not any(role.id == self.muted_role.id for role in member.roles): + remove_role_after = AntiSpamConfig.punishment['remove_after'] + + # Get context and make sure the bot becomes the actor of infraction by patching the `author` attributes + context = await self.bot.get_context(msg) + context.author = self.bot.user + context.message.author = self.bot.user + + # Since we're going to invoke the tempmute command directly, we need to manually call the converter. + dt_remove_role_after = await self.expiration_date_converter.convert(context, f"{remove_role_after}S") + await context.invoke( + self.bot.get_command('tempmute'), + member, + dt_remove_role_after, + reason=reason + ) + + async def maybe_delete_messages(self, channel: TextChannel, messages: List[Message]) -> None: + """Cleans the messages if cleaning is configured.""" + if AntiSpamConfig.clean_offending: + # If we have more than one message, we can use bulk delete. + if len(messages) > 1: + message_ids = [message.id for message in messages] + self.mod_log.ignore(Event.message_delete, *message_ids) + await channel.delete_messages(messages) + + # Otherwise, the bulk delete endpoint will throw up. + # Delete the message directly instead. + else: + self.mod_log.ignore(Event.message_delete, messages[0].id) + try: + await messages[0].delete() + except NotFound: + log.info(f"Tried to delete message `{messages[0].id}`, but message could not be found.") + + async def _process_deletion_context(self, context_id: int) -> None: + """Processes the Deletion Context queue.""" + log.trace("Sleeping before processing message deletion queue.") + await asyncio.sleep(10) + + if context_id not in self.message_deletion_queue: + log.error(f"Started processing deletion queue for context `{context_id}`, but it was not found!") + return + + deletion_context = self.message_deletion_queue.pop(context_id) + await deletion_context.upload_messages(self.bot.user.id, self.mod_log) + + +def validate_config(rules_: Mapping = AntiSpamConfig.rules) -> Dict[str, str]: + """Validates the antispam configs.""" + validation_errors = {} + for name, config in rules_.items(): + if name not in RULE_FUNCTION_MAPPING: + log.error( + f"Unrecognized antispam rule `{name}`. " + f"Valid rules are: {', '.join(RULE_FUNCTION_MAPPING)}" + ) + validation_errors[name] = f"`{name}` is not recognized as an antispam rule." + continue + for required_key in ('interval', 'max'): + if required_key not in config: + log.error( + f"`{required_key}` is required but was not " + f"set in rule `{name}`'s configuration." + ) + validation_errors[name] = f"Key `{required_key}` is required but not set for rule `{name}`" + return validation_errors + + +def setup(bot: Bot) -> None: + """Validate the AntiSpam configs and load the AntiSpam cog.""" + validation_errors = validate_config() + bot.add_cog(AntiSpam(bot, validation_errors)) diff --git a/bot/exts/filters/filter_lists.py b/bot/exts/filters/filter_lists.py new file mode 100644 index 000000000..c15adc461 --- /dev/null +++ b/bot/exts/filters/filter_lists.py @@ -0,0 +1,273 @@ +import logging +from typing import Optional + +from discord import Colour, Embed +from discord.ext.commands import BadArgument, Cog, Context, IDConverter, group + +from bot import constants +from bot.api import ResponseCodeError +from bot.bot import Bot +from bot.converters import ValidDiscordServerInvite, ValidFilterListType +from bot.pagination import LinePaginator +from bot.utils.checks import with_role_check + +log = logging.getLogger(__name__) + + +class FilterLists(Cog): + """Commands for blacklisting and whitelisting things.""" + + methods_with_filterlist_types = [ + "allow_add", + "allow_delete", + "allow_get", + "deny_add", + "deny_delete", + "deny_get", + ] + + def __init__(self, bot: Bot) -> None: + self.bot = bot + self.bot.loop.create_task(self._amend_docstrings()) + + async def _amend_docstrings(self) -> None: + """Add the valid FilterList types to the docstrings, so they'll appear in !help invocations.""" + await self.bot.wait_until_guild_available() + + # Add valid filterlist types to the docstrings + valid_types = await ValidFilterListType.get_valid_types(self.bot) + valid_types = [f"`{type_.lower()}`" for type_ in valid_types] + + for method_name in self.methods_with_filterlist_types: + command = getattr(self, method_name) + command.help = ( + f"{command.help}\n\nValid **list_type** values are {', '.join(valid_types)}." + ) + + async def _add_data( + self, + ctx: Context, + allowed: bool, + list_type: ValidFilterListType, + content: str, + comment: Optional[str] = None, + ) -> None: + """Add an item to a filterlist.""" + allow_type = "whitelist" if allowed else "blacklist" + + # If this is a server invite, we gotta validate it. + if list_type == "GUILD_INVITE": + guild_data = await self._validate_guild_invite(ctx, content) + content = guild_data.get("id") + + # Unless the user has specified another comment, let's + # use the server name as the comment so that the list + # of guild IDs will be more easily readable when we + # display it. + if not comment: + comment = guild_data.get("name") + + # If it's a file format, let's make sure it has a leading dot. + elif list_type == "FILE_FORMAT" and not content.startswith("."): + content = f".{content}" + + # Try to add the item to the database + log.trace(f"Trying to add the {content} item to the {list_type} {allow_type}") + payload = { + "allowed": allowed, + "type": list_type, + "content": content, + "comment": comment, + } + + try: + item = await self.bot.api_client.post( + "bot/filter-lists", + json=payload + ) + except ResponseCodeError as e: + if e.status == 400: + await ctx.message.add_reaction("❌") + log.debug( + f"{ctx.author} tried to add data to a {allow_type}, but the API returned 400, " + "probably because the request violated the UniqueConstraint." + ) + raise BadArgument( + f"Unable to add the item to the {allow_type}. " + "The item probably already exists. Keep in mind that a " + "blacklist and a whitelist for the same item cannot co-exist, " + "and we do not permit any duplicates." + ) + raise + + # Insert the item into the cache + self.bot.insert_item_into_filter_list_cache(item) + await ctx.message.add_reaction("✅") + + async def _delete_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType, content: str) -> None: + """Remove an item from a filterlist.""" + allow_type = "whitelist" if allowed else "blacklist" + + # If this is a server invite, we need to convert it. + if list_type == "GUILD_INVITE" and not IDConverter()._get_id_match(content): + guild_data = await self._validate_guild_invite(ctx, content) + content = guild_data.get("id") + + # If it's a file format, let's make sure it has a leading dot. + elif list_type == "FILE_FORMAT" and not content.startswith("."): + content = f".{content}" + + # Find the content and delete it. + log.trace(f"Trying to delete the {content} item from the {list_type} {allow_type}") + item = self.bot.filter_list_cache[f"{list_type}.{allowed}"].get(content) + + if item is not None: + try: + await self.bot.api_client.delete( + f"bot/filter-lists/{item['id']}" + ) + del self.bot.filter_list_cache[f"{list_type}.{allowed}"][content] + await ctx.message.add_reaction("✅") + except ResponseCodeError as e: + log.debug( + f"{ctx.author} tried to delete an item with the id {item['id']}, but " + f"the API raised an unexpected error: {e}" + ) + await ctx.message.add_reaction("❌") + else: + await ctx.message.add_reaction("❌") + + async def _list_all_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType) -> None: + """Paginate and display all items in a filterlist.""" + allow_type = "whitelist" if allowed else "blacklist" + result = self.bot.filter_list_cache[f"{list_type}.{allowed}"] + + # Build a list of lines we want to show in the paginator + lines = [] + for content, metadata in result.items(): + line = f"• `{content}`" + + if comment := metadata.get("comment"): + line += f" - {comment}" + + lines.append(line) + lines = sorted(lines) + + # Build the embed + list_type_plural = list_type.lower().replace("_", " ").title() + "s" + embed = Embed( + title=f"{allow_type.title()}ed {list_type_plural} ({len(result)} total)", + colour=Colour.blue() + ) + log.trace(f"Trying to list {len(result)} items from the {list_type.lower()} {allow_type}") + + if result: + await LinePaginator.paginate(lines, ctx, embed, max_lines=15, empty=False) + else: + embed.description = "Hmmm, seems like there's nothing here yet." + await ctx.send(embed=embed) + await ctx.message.add_reaction("❌") + + async def _sync_data(self, ctx: Context) -> None: + """Syncs the filterlists with the API.""" + try: + log.trace("Attempting to sync FilterList cache with data from the API.") + await self.bot.cache_filter_list_data() + await ctx.message.add_reaction("✅") + except ResponseCodeError as e: + log.debug( + f"{ctx.author} tried to sync FilterList cache data but " + f"the API raised an unexpected error: {e}" + ) + await ctx.message.add_reaction("❌") + + @staticmethod + async def _validate_guild_invite(ctx: Context, invite: str) -> dict: + """ + Validates a guild invite, and returns the guild info as a dict. + + Will raise a BadArgument if the guild invite is invalid. + """ + log.trace(f"Attempting to validate whether or not {invite} is a guild invite.") + validator = ValidDiscordServerInvite() + guild_data = await validator.convert(ctx, invite) + + # If we make it this far without raising a BadArgument, the invite is + # valid. Let's return a dict of guild information. + log.trace(f"{invite} validated as server invite. Converting to ID.") + return guild_data + + @group(aliases=("allowlist", "allow", "al", "wl")) + async def whitelist(self, ctx: Context) -> None: + """Group for whitelisting commands.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @group(aliases=("denylist", "deny", "bl", "dl")) + async def blacklist(self, ctx: Context) -> None: + """Group for blacklisting commands.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @whitelist.command(name="add", aliases=("a", "set")) + async def allow_add( + self, + ctx: Context, + list_type: ValidFilterListType, + content: str, + *, + comment: Optional[str] = None, + ) -> None: + """Add an item to the specified allowlist.""" + await self._add_data(ctx, True, list_type, content, comment) + + @blacklist.command(name="add", aliases=("a", "set")) + async def deny_add( + self, + ctx: Context, + list_type: ValidFilterListType, + content: str, + *, + comment: Optional[str] = None, + ) -> None: + """Add an item to the specified denylist.""" + await self._add_data(ctx, False, list_type, content, comment) + + @whitelist.command(name="remove", aliases=("delete", "rm",)) + async def allow_delete(self, ctx: Context, list_type: ValidFilterListType, content: str) -> None: + """Remove an item from the specified allowlist.""" + await self._delete_data(ctx, True, list_type, content) + + @blacklist.command(name="remove", aliases=("delete", "rm",)) + async def deny_delete(self, ctx: Context, list_type: ValidFilterListType, content: str) -> None: + """Remove an item from the specified denylist.""" + await self._delete_data(ctx, False, list_type, content) + + @whitelist.command(name="get", aliases=("list", "ls", "fetch", "show")) + async def allow_get(self, ctx: Context, list_type: ValidFilterListType) -> None: + """Get the contents of a specified allowlist.""" + await self._list_all_data(ctx, True, list_type) + + @blacklist.command(name="get", aliases=("list", "ls", "fetch", "show")) + async def deny_get(self, ctx: Context, list_type: ValidFilterListType) -> None: + """Get the contents of a specified denylist.""" + await self._list_all_data(ctx, False, list_type) + + @whitelist.command(name="sync", aliases=("s",)) + async def allow_sync(self, ctx: Context) -> None: + """Syncs both allowlists and denylists with the API.""" + await self._sync_data(ctx) + + @blacklist.command(name="sync", aliases=("s",)) + async def deny_sync(self, ctx: Context) -> None: + """Syncs both allowlists and denylists with the API.""" + await self._sync_data(ctx) + + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + return with_role_check(ctx, *constants.MODERATION_ROLES) + + +def setup(bot: Bot) -> None: + """Load the FilterLists cog.""" + bot.add_cog(FilterLists(bot)) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py new file mode 100644 index 000000000..2ae476d8a --- /dev/null +++ b/bot/exts/filters/filtering.py @@ -0,0 +1,575 @@ +import asyncio +import logging +import re +from datetime import datetime, timedelta +from typing import List, Mapping, Optional, Tuple, Union + +import dateutil +import discord.errors +from dateutil.relativedelta import relativedelta +from discord import Colour, HTTPException, Member, Message, NotFound, TextChannel +from discord.ext.commands import Cog +from discord.utils import escape_markdown + +from bot.bot import Bot +from bot.constants import ( + Channels, Colours, + Filter, Icons, URLs +) +from bot.exts.moderation.modlog import ModLog +from bot.utils.redis_cache import RedisCache +from bot.utils.regex import INVITE_RE +from bot.utils.scheduling import Scheduler + +log = logging.getLogger(__name__) + +# Regular expressions +SPOILER_RE = re.compile(r"(\|\|.+?\|\|)", re.DOTALL) +URL_RE = re.compile(r"(https?://[^\s]+)", flags=re.IGNORECASE) +ZALGO_RE = re.compile(r"[\u0300-\u036F\u0489]") + +# Other constants. +DAYS_BETWEEN_ALERTS = 3 +OFFENSIVE_MSG_DELETE_TIME = timedelta(days=Filter.offensive_msg_delete_days) + + +class Filtering(Cog): + """Filtering out invites, blacklisting domains, and warning us of certain regular expressions.""" + + # Redis cache mapping a user ID to the last timestamp a bad nickname alert was sent + name_alerts = RedisCache() + + def __init__(self, bot: Bot): + self.bot = bot + self.scheduler = Scheduler(self.__class__.__name__) + self.name_lock = asyncio.Lock() + + staff_mistake_str = "If you believe this was a mistake, please let staff know!" + self.filters = { + "filter_zalgo": { + "enabled": Filter.filter_zalgo, + "function": self._has_zalgo, + "type": "filter", + "content_only": True, + "user_notification": Filter.notify_user_zalgo, + "notification_msg": ( + "Your post has been removed for abusing Unicode character rendering (aka Zalgo text). " + f"{staff_mistake_str}" + ), + "schedule_deletion": False + }, + "filter_invites": { + "enabled": Filter.filter_invites, + "function": self._has_invites, + "type": "filter", + "content_only": True, + "user_notification": Filter.notify_user_invites, + "notification_msg": ( + f"Per Rule 6, your invite link has been removed. {staff_mistake_str}\n\n" + r"Our server rules can be found here: " + ), + "schedule_deletion": False + }, + "filter_domains": { + "enabled": Filter.filter_domains, + "function": self._has_urls, + "type": "filter", + "content_only": True, + "user_notification": Filter.notify_user_domains, + "notification_msg": ( + f"Your URL has been removed because it matched a blacklisted domain. {staff_mistake_str}" + ), + "schedule_deletion": False + }, + "watch_regex": { + "enabled": Filter.watch_regex, + "function": self._has_watch_regex_match, + "type": "watchlist", + "content_only": True, + "schedule_deletion": True + }, + "watch_rich_embeds": { + "enabled": Filter.watch_rich_embeds, + "function": self._has_rich_embed, + "type": "watchlist", + "content_only": False, + "schedule_deletion": False + } + } + + self.bot.loop.create_task(self.reschedule_offensive_msg_deletion()) + + def cog_unload(self) -> None: + """Cancel scheduled tasks.""" + self.scheduler.cancel_all() + + def _get_filterlist_items(self, list_type: str, *, allowed: bool) -> list: + """Fetch items from the filter_list_cache.""" + return self.bot.filter_list_cache[f"{list_type.upper()}.{allowed}"].keys() + + @staticmethod + def _expand_spoilers(text: str) -> str: + """Return a string containing all interpretations of a spoilered message.""" + split_text = SPOILER_RE.split(text) + return ''.join( + split_text[0::2] + split_text[1::2] + split_text + ) + + @property + def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + @Cog.listener() + async def on_message(self, msg: Message) -> None: + """Invoke message filter for new messages.""" + await self._filter_message(msg) + + # Ignore webhook messages. + if msg.webhook_id is None: + await self.check_bad_words_in_name(msg.author) + + @Cog.listener() + async def on_message_edit(self, before: Message, after: Message) -> None: + """ + Invoke message filter for message edits. + + If there have been multiple edits, calculate the time delta from the previous edit. + """ + if not before.edited_at: + delta = relativedelta(after.edited_at, before.created_at).microseconds + else: + delta = relativedelta(after.edited_at, before.edited_at).microseconds + await self._filter_message(after, delta) + + def get_name_matches(self, name: str) -> List[re.Match]: + """Check bad words from passed string (name). Return list of matches.""" + matches = [] + watchlist_patterns = self._get_filterlist_items('filter_token', allowed=False) + for pattern in watchlist_patterns: + if match := re.search(pattern, name, flags=re.IGNORECASE): + matches.append(match) + return matches + + async def check_send_alert(self, member: Member) -> bool: + """When there is less than 3 days after last alert, return `False`, otherwise `True`.""" + if last_alert := await self.name_alerts.get(member.id): + last_alert = datetime.utcfromtimestamp(last_alert) + if datetime.utcnow() - timedelta(days=DAYS_BETWEEN_ALERTS) < last_alert: + log.trace(f"Last alert was too recent for {member}'s nickname.") + return False + + return True + + async def check_bad_words_in_name(self, member: Member) -> None: + """Send a mod alert every 3 days if a username still matches a watchlist pattern.""" + # Use lock to avoid race conditions + async with self.name_lock: + # Check whether the users display name contains any words in our blacklist + matches = self.get_name_matches(member.display_name) + + if not matches or not await self.check_send_alert(member): + return + + log.info(f"Sending bad nickname alert for '{member.display_name}' ({member.id}).") + + log_string = ( + f"**User:** {member.mention} (`{member.id}`)\n" + f"**Display Name:** {member.display_name}\n" + f"**Bad Matches:** {', '.join(match.group() for match in matches)}" + ) + + await self.mod_log.send_log_message( + icon_url=Icons.token_removed, + colour=Colours.soft_red, + title="Username filtering alert", + text=log_string, + channel_id=Channels.mod_alerts, + thumbnail=member.avatar_url + ) + + # Update time when alert sent + await self.name_alerts.set(member.id, datetime.utcnow().timestamp()) + + async def filter_eval(self, result: str, msg: Message) -> bool: + """ + Filter the result of an !eval to see if it violates any of our rules, and then respond accordingly. + + Also requires the original message, to check whether to filter and for mod logs. + Returns whether a filter was triggered or not. + """ + filter_triggered = False + # Should we filter this message? + if self._check_filter(msg): + for filter_name, _filter in self.filters.items(): + # Is this specific filter enabled in the config? + # We also do not need to worry about filters that take the full message, + # since all we have is an arbitrary string. + if _filter["enabled"] and _filter["content_only"]: + match = await _filter["function"](result) + + if match: + # If this is a filter (not a watchlist), we set the variable so we know + # that it has been triggered + if _filter["type"] == "filter": + filter_triggered = True + + # We do not have to check against DM channels since !eval cannot be used there. + channel_str = f"in {msg.channel.mention}" + + message_content, additional_embeds, additional_embeds_msg = self._add_stats( + filter_name, match, result + ) + + message = ( + f"The {filter_name} {_filter['type']} was triggered " + f"by **{msg.author}** " + f"(`{msg.author.id}`) {channel_str} using !eval with " + f"[the following message]({msg.jump_url}):\n\n" + f"{message_content}" + ) + + log.debug(message) + + # Send pretty mod log embed to mod-alerts + await self.mod_log.send_log_message( + icon_url=Icons.filtering, + colour=Colour(Colours.soft_red), + title=f"{_filter['type'].title()} triggered!", + text=message, + thumbnail=msg.author.avatar_url_as(static_format="png"), + channel_id=Channels.mod_alerts, + ping_everyone=Filter.ping_everyone, + additional_embeds=additional_embeds, + additional_embeds_msg=additional_embeds_msg + ) + + break # We don't want multiple filters to trigger + + return filter_triggered + + async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> None: + """Filter the input message to see if it violates any of our rules, and then respond accordingly.""" + # Should we filter this message? + if self._check_filter(msg): + for filter_name, _filter in self.filters.items(): + # Is this specific filter enabled in the config? + if _filter["enabled"]: + # Double trigger check for the embeds filter + if filter_name == "watch_rich_embeds": + # If the edit delta is less than 0.001 seconds, then we're probably dealing + # with a double filter trigger. + if delta is not None and delta < 100: + continue + + # Does the filter only need the message content or the full message? + if _filter["content_only"]: + match = await _filter["function"](msg.content) + else: + match = await _filter["function"](msg) + + if match: + is_private = msg.channel.type is discord.ChannelType.private + + # If this is a filter (not a watchlist) and not in a DM, delete the message. + if _filter["type"] == "filter" and not is_private: + try: + # Embeds (can?) trigger both the `on_message` and `on_message_edit` + # event handlers, triggering filtering twice for the same message. + # + # If `on_message`-triggered filtering already deleted the message + # then `on_message_edit`-triggered filtering will raise exception + # since the message no longer exists. + # + # In addition, to avoid sending two notifications to the user, the + # logs, and mod_alert, we return if the message no longer exists. + await msg.delete() + except discord.errors.NotFound: + return + + # Notify the user if the filter specifies + if _filter["user_notification"]: + await self.notify_member(msg.author, _filter["notification_msg"], msg.channel) + + # If the message is classed as offensive, we store it in the site db and + # it will be deleted it after one week. + if _filter["schedule_deletion"] and not is_private: + delete_date = (msg.created_at + OFFENSIVE_MSG_DELETE_TIME).isoformat() + data = { + 'id': msg.id, + 'channel_id': msg.channel.id, + 'delete_date': delete_date + } + + await self.bot.api_client.post('bot/offensive-messages', json=data) + self.schedule_msg_delete(data) + log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}") + + if is_private: + channel_str = "via DM" + else: + channel_str = f"in {msg.channel.mention}" + + message_content, additional_embeds, additional_embeds_msg = self._add_stats( + filter_name, match, msg.content + ) + + message = ( + f"The {filter_name} {_filter['type']} was triggered " + f"by **{msg.author}** " + f"(`{msg.author.id}`) {channel_str} with [the " + f"following message]({msg.jump_url}):\n\n" + f"{message_content}" + ) + + log.debug(message) + + # Send pretty mod log embed to mod-alerts + await self.mod_log.send_log_message( + icon_url=Icons.filtering, + colour=Colour(Colours.soft_red), + title=f"{_filter['type'].title()} triggered!", + text=message, + thumbnail=msg.author.avatar_url_as(static_format="png"), + channel_id=Channels.mod_alerts, + ping_everyone=Filter.ping_everyone if not is_private else False, + additional_embeds=additional_embeds, + additional_embeds_msg=additional_embeds_msg + ) + + break # We don't want multiple filters to trigger + + def _add_stats(self, name: str, match: Union[re.Match, dict, bool, List[discord.Embed]], content: str) -> Tuple[ + str, Optional[List[discord.Embed]], Optional[str] + ]: + """Adds relevant statistical information to the relevant filter and increments the bot's stats.""" + # Word and match stats for watch_regex + if name == "watch_regex": + surroundings = match.string[max(match.start() - 10, 0): match.end() + 10] + message_content = ( + f"**Match:** '{match[0]}'\n" + f"**Location:** '...{escape_markdown(surroundings)}...'\n" + f"\n**Original Message:**\n{escape_markdown(content)}" + ) + else: # Use original content + message_content = content + + additional_embeds = None + additional_embeds_msg = None + + self.bot.stats.incr(f"filters.{name}") + + # The function returns True for invalid invites. + # They have no data so additional embeds can't be created for them. + if name == "filter_invites" and match is not True: + additional_embeds = [] + for _, data in match.items(): + embed = discord.Embed(description=( + f"**Members:**\n{data['members']}\n" + f"**Active:**\n{data['active']}" + )) + embed.set_author(name=data["name"]) + embed.set_thumbnail(url=data["icon"]) + embed.set_footer(text=f"Guild ID: {data['id']}") + additional_embeds.append(embed) + additional_embeds_msg = "For the following guild(s):" + + elif name == "watch_rich_embeds": + additional_embeds = match + additional_embeds_msg = "With the following embed(s):" + + return message_content, additional_embeds, additional_embeds_msg + + @staticmethod + def _check_filter(msg: Message) -> bool: + """Check whitelists to see if we should filter this message.""" + role_whitelisted = False + + if type(msg.author) is Member: # Only Member has roles, not User. + for role in msg.author.roles: + if role.id in Filter.role_whitelist: + role_whitelisted = True + + return ( + msg.channel.id not in Filter.channel_whitelist # Channel not in whitelist + and not role_whitelisted # Role not in whitelist + and not msg.author.bot # Author not a bot + ) + + async def _has_watch_regex_match(self, text: str) -> Union[bool, re.Match]: + """ + Return True if `text` matches any regex from `word_watchlist` or `token_watchlist` configs. + + `word_watchlist`'s patterns are placed between word boundaries while `token_watchlist` is + matched as-is. Spoilers are expanded, if any, and URLs are ignored. + """ + if SPOILER_RE.search(text): + text = self._expand_spoilers(text) + + # Make sure it's not a URL + if URL_RE.search(text): + return False + + watchlist_patterns = self._get_filterlist_items('filter_token', allowed=False) + for pattern in watchlist_patterns: + match = re.search(pattern, text, flags=re.IGNORECASE) + if match: + return match + + async def _has_urls(self, text: str) -> bool: + """Returns True if the text contains one of the blacklisted URLs from the config file.""" + if not URL_RE.search(text): + return False + + text = text.lower() + domain_blacklist = self._get_filterlist_items("domain_name", allowed=False) + + for url in domain_blacklist: + if url.lower() in text: + return True + + return False + + @staticmethod + async def _has_zalgo(text: str) -> bool: + """ + Returns True if the text contains zalgo characters. + + Zalgo range is \u0300 – \u036F and \u0489. + """ + return bool(ZALGO_RE.search(text)) + + async def _has_invites(self, text: str) -> Union[dict, bool]: + """ + Checks if there's any invites in the text content that aren't in the guild whitelist. + + If any are detected, a dictionary of invite data is returned, with a key per invite. + If none are detected, False is returned. + + Attempts to catch some of common ways to try to cheat the system. + """ + # Remove backslashes to prevent escape character aroundfuckery like + # discord\.gg/gdudes-pony-farm + text = text.replace("\\", "") + + invites = INVITE_RE.findall(text) + invite_data = dict() + for invite in invites: + if invite in invite_data: + continue + + response = await self.bot.http_session.get( + f"{URLs.discord_invite_api}/{invite}", params={"with_counts": "true"} + ) + response = await response.json() + guild = response.get("guild") + if guild is None: + # Lack of a "guild" key in the JSON response indicates either an group DM invite, an + # expired invite, or an invalid invite. The API does not currently differentiate + # between invalid and expired invites + return True + + guild_id = guild.get("id") + guild_invite_whitelist = self._get_filterlist_items("guild_invite", allowed=True) + guild_invite_blacklist = self._get_filterlist_items("guild_invite", allowed=False) + + # Is this invite allowed? + guild_partnered_or_verified = ( + 'PARTNERED' in guild.get("features", []) + or 'VERIFIED' in guild.get("features", []) + ) + invite_not_allowed = ( + guild_id in guild_invite_blacklist # Blacklisted guilds are never permitted. + or guild_id not in guild_invite_whitelist # Whitelisted guilds are always permitted. + and not guild_partnered_or_verified # Otherwise guilds have to be Verified or Partnered. + ) + + if invite_not_allowed: + guild_icon_hash = guild["icon"] + guild_icon = ( + "https://cdn.discordapp.com/icons/" + f"{guild_id}/{guild_icon_hash}.png?size=512" + ) + + invite_data[invite] = { + "name": guild["name"], + "id": guild['id'], + "icon": guild_icon, + "members": response["approximate_member_count"], + "active": response["approximate_presence_count"] + } + + return invite_data if invite_data else False + + @staticmethod + async def _has_rich_embed(msg: Message) -> Union[bool, List[discord.Embed]]: + """Determines if `msg` contains any rich embeds not auto-generated from a URL.""" + if msg.embeds: + for embed in msg.embeds: + if embed.type == "rich": + urls = URL_RE.findall(msg.content) + if not embed.url or embed.url not in urls: + # If `embed.url` does not exist or if `embed.url` is not part of the content + # of the message, it's unlikely to be an auto-generated embed by Discord. + return msg.embeds + else: + log.trace( + "Found a rich embed sent by a regular user account, " + "but it was likely just an automatic URL embed." + ) + return False + return False + + async def notify_member(self, filtered_member: Member, reason: str, channel: TextChannel) -> None: + """ + Notify filtered_member about a moderation action with the reason str. + + First attempts to DM the user, fall back to in-channel notification if user has DMs disabled + """ + try: + await filtered_member.send(reason) + except discord.errors.Forbidden: + await channel.send(f"{filtered_member.mention} {reason}") + + def schedule_msg_delete(self, msg: dict) -> None: + """Delete an offensive message once its deletion date is reached.""" + delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None) + self.scheduler.schedule_at(delete_at, msg['id'], self.delete_offensive_msg(msg)) + + async def reschedule_offensive_msg_deletion(self) -> None: + """Get all the pending message deletion from the API and reschedule them.""" + await self.bot.wait_until_ready() + response = await self.bot.api_client.get('bot/offensive-messages',) + + now = datetime.utcnow() + + for msg in response: + delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None) + + if delete_at < now: + await self.delete_offensive_msg(msg) + else: + self.schedule_msg_delete(msg) + + async def delete_offensive_msg(self, msg: Mapping[str, str]) -> None: + """Delete an offensive message, and then delete it from the db.""" + try: + channel = self.bot.get_channel(msg['channel_id']) + if channel: + msg_obj = await channel.fetch_message(msg['id']) + await msg_obj.delete() + except NotFound: + log.info( + f"Tried to delete message {msg['id']}, but the message can't be found " + f"(it has been probably already deleted)." + ) + except HTTPException as e: + log.warning(f"Failed to delete message {msg['id']}: status {e.status}") + + await self.bot.api_client.delete(f'bot/offensive-messages/{msg["id"]}') + log.info(f"Deleted the offensive message with id {msg['id']}.") + + +def setup(bot: Bot) -> None: + """Load the Filtering cog.""" + bot.add_cog(Filtering(bot)) diff --git a/bot/exts/filters/security.py b/bot/exts/filters/security.py new file mode 100644 index 000000000..c680c5e27 --- /dev/null +++ b/bot/exts/filters/security.py @@ -0,0 +1,31 @@ +import logging + +from discord.ext.commands import Cog, Context, NoPrivateMessage + +from bot.bot import Bot + +log = logging.getLogger(__name__) + + +class Security(Cog): + """Security-related helpers.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.bot.check(self.check_not_bot) # Global commands check - no bots can run any commands at all + self.bot.check(self.check_on_guild) # Global commands check - commands can't be run in a DM + + def check_not_bot(self, ctx: Context) -> bool: + """Check if the context is a bot user.""" + return not ctx.author.bot + + def check_on_guild(self, ctx: Context) -> bool: + """Check if the context is in a guild.""" + if ctx.guild is None: + raise NoPrivateMessage("This command cannot be used in private messages.") + return True + + +def setup(bot: Bot) -> None: + """Load the Security cog.""" + bot.add_cog(Security(bot)) diff --git a/bot/exts/filters/token_remover.py b/bot/exts/filters/token_remover.py new file mode 100644 index 000000000..0eda3dc6a --- /dev/null +++ b/bot/exts/filters/token_remover.py @@ -0,0 +1,182 @@ +import base64 +import binascii +import logging +import re +import typing as t + +from discord import Colour, Message, NotFound +from discord.ext.commands import Cog + +from bot import utils +from bot.bot import Bot +from bot.constants import Channels, Colours, Event, Icons +from bot.exts.moderation.modlog import ModLog + +log = logging.getLogger(__name__) + +LOG_MESSAGE = ( + "Censored a seemingly valid token sent by {author} (`{author_id}`) in {channel}, " + "token was `{user_id}.{timestamp}.{hmac}`" +) +DELETION_MESSAGE_TEMPLATE = ( + "Hey {mention}! I noticed you posted a seemingly valid Discord API " + "token in your message and have removed your message. " + "This means that your token has been **compromised**. " + "Please change your token **immediately** at: " + "\n\n" + "Feel free to re-post it with the token removed. " + "If you believe this was a mistake, please let us know!" +) +DISCORD_EPOCH = 1_420_070_400 +TOKEN_EPOCH = 1_293_840_000 + +# Three parts delimited by dots: user ID, creation timestamp, HMAC. +# The HMAC isn't parsed further, but it's in the regex to ensure it at least exists in the string. +# Each part only matches base64 URL-safe characters. +# Padding has never been observed, but the padding character '=' is matched just in case. +TOKEN_RE = re.compile(r"([\w\-=]+)\.([\w\-=]+)\.([\w\-=]+)", re.ASCII) + + +class Token(t.NamedTuple): + """A Discord Bot token.""" + + user_id: str + timestamp: str + hmac: str + + +class TokenRemover(Cog): + """Scans messages for potential discord.py bot tokens and removes them.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @property + def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + @Cog.listener() + async def on_message(self, msg: Message) -> None: + """ + Check each message for a string that matches Discord's token pattern. + + See: https://discordapp.com/developers/docs/reference#snowflakes + """ + # Ignore DMs; can't delete messages in there anyway. + if not msg.guild or msg.author.bot: + return + + found_token = self.find_token_in_message(msg) + if found_token: + await self.take_action(msg, found_token) + + @Cog.listener() + async def on_message_edit(self, before: Message, after: Message) -> None: + """ + Check each edit for a string that matches Discord's token pattern. + + See: https://discordapp.com/developers/docs/reference#snowflakes + """ + await self.on_message(after) + + async def take_action(self, msg: Message, found_token: Token) -> None: + """Remove the `msg` containing the `found_token` and send a mod log message.""" + self.mod_log.ignore(Event.message_delete, msg.id) + + try: + await msg.delete() + except NotFound: + log.debug(f"Failed to remove token in message {msg.id}: message already deleted.") + return + + await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention)) + + log_message = self.format_log_message(msg, found_token) + log.debug(log_message) + + # Send pretty mod log embed to mod-alerts + await self.mod_log.send_log_message( + icon_url=Icons.token_removed, + colour=Colour(Colours.soft_red), + title="Token removed!", + text=log_message, + thumbnail=msg.author.avatar_url_as(static_format="png"), + channel_id=Channels.mod_alerts, + ) + + self.bot.stats.incr("tokens.removed_tokens") + + @staticmethod + def format_log_message(msg: Message, token: Token) -> str: + """Return the log message to send for `token` being censored in `msg`.""" + return LOG_MESSAGE.format( + author=msg.author, + author_id=msg.author.id, + channel=msg.channel.mention, + user_id=token.user_id, + timestamp=token.timestamp, + hmac='x' * len(token.hmac), + ) + + @classmethod + def find_token_in_message(cls, msg: Message) -> t.Optional[Token]: + """Return a seemingly valid token found in `msg` or `None` if no token is found.""" + # Use finditer rather than search to guard against method calls prematurely returning the + # token check (e.g. `message.channel.send` also matches our token pattern) + for match in TOKEN_RE.finditer(msg.content): + token = Token(*match.groups()) + if cls.is_valid_user_id(token.user_id) and cls.is_valid_timestamp(token.timestamp): + # Short-circuit on first match + return token + + # No matching substring + return + + @staticmethod + def is_valid_user_id(b64_content: str) -> bool: + """ + Check potential token to see if it contains a valid Discord user ID. + + See: https://discordapp.com/developers/docs/reference#snowflakes + """ + b64_content = utils.pad_base64(b64_content) + + try: + decoded_bytes = base64.urlsafe_b64decode(b64_content) + string = decoded_bytes.decode('utf-8') + + # isdigit on its own would match a lot of other Unicode characters, hence the isascii. + return string.isascii() and string.isdigit() + except (binascii.Error, ValueError): + return False + + @staticmethod + def is_valid_timestamp(b64_content: str) -> bool: + """ + Return True if `b64_content` decodes to a valid timestamp. + + If the timestamp is greater than the Discord epoch, it's probably valid. + See: https://i.imgur.com/7WdehGn.png + """ + b64_content = utils.pad_base64(b64_content) + + try: + decoded_bytes = base64.urlsafe_b64decode(b64_content) + timestamp = int.from_bytes(decoded_bytes, byteorder="big") + except (binascii.Error, ValueError) as e: + log.debug(f"Failed to decode token timestamp '{b64_content}': {e}") + return False + + # Seems like newer tokens don't need the epoch added, but add anyway since an upper bound + # is not checked. + if timestamp + TOKEN_EPOCH >= DISCORD_EPOCH: + return True + else: + log.debug(f"Invalid token timestamp '{b64_content}': smaller than Discord epoch") + return False + + +def setup(bot: Bot) -> None: + """Load the TokenRemover cog.""" + bot.add_cog(TokenRemover(bot)) diff --git a/bot/exts/filters/webhook_remover.py b/bot/exts/filters/webhook_remover.py new file mode 100644 index 000000000..ca126ebf5 --- /dev/null +++ b/bot/exts/filters/webhook_remover.py @@ -0,0 +1,84 @@ +import logging +import re + +from discord import Colour, Message, NotFound +from discord.ext.commands import Cog + +from bot.bot import Bot +from bot.constants import Channels, Colours, Event, Icons +from bot.exts.moderation.modlog import ModLog + +WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\d+/)\S+/?", re.IGNORECASE) + +ALERT_MESSAGE_TEMPLATE = ( + "{user}, looks like you posted a Discord webhook URL. Therefore, your " + "message has been removed. Your webhook may have been **compromised** so " + "please re-create the webhook **immediately**. If you believe this was " + "mistake, please let us know." +) + +log = logging.getLogger(__name__) + + +class WebhookRemover(Cog): + """Scan messages to detect Discord webhooks links.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @property + def mod_log(self) -> ModLog: + """Get current instance of `ModLog`.""" + return self.bot.get_cog("ModLog") + + async def delete_and_respond(self, msg: Message, redacted_url: str) -> None: + """Delete `msg` and send a warning that it contained the Discord webhook `redacted_url`.""" + # Don't log this, due internal delete, not by user. Will make different entry. + self.mod_log.ignore(Event.message_delete, msg.id) + + try: + await msg.delete() + except NotFound: + log.debug(f"Failed to remove webhook in message {msg.id}: message already deleted.") + return + + await msg.channel.send(ALERT_MESSAGE_TEMPLATE.format(user=msg.author.mention)) + + message = ( + f"{msg.author} (`{msg.author.id}`) posted a Discord webhook URL " + f"to #{msg.channel}. Webhook URL was `{redacted_url}`" + ) + log.debug(message) + + # Send entry to moderation alerts. + await self.mod_log.send_log_message( + icon_url=Icons.token_removed, + colour=Colour(Colours.soft_red), + title="Discord webhook URL removed!", + text=message, + thumbnail=msg.author.avatar_url_as(static_format="png"), + channel_id=Channels.mod_alerts + ) + + self.bot.stats.incr("tokens.removed_webhooks") + + @Cog.listener() + async def on_message(self, msg: Message) -> None: + """Check if a Discord webhook URL is in `message`.""" + # Ignore DMs; can't delete messages in there anyway. + if not msg.guild or msg.author.bot: + return + + matches = WEBHOOK_URL_RE.search(msg.content) + if matches: + await self.delete_and_respond(msg, matches[1] + "xxx") + + @Cog.listener() + async def on_message_edit(self, before: Message, after: Message) -> None: + """Check if a Discord webhook URL is in the edited message `after`.""" + await self.on_message(after) + + +def setup(bot: Bot) -> None: + """Load `WebhookRemover` cog.""" + bot.add_cog(WebhookRemover(bot)) diff --git a/bot/exts/help_channels.py b/bot/exts/help_channels.py new file mode 100644 index 000000000..57094751e --- /dev/null +++ b/bot/exts/help_channels.py @@ -0,0 +1,944 @@ +import asyncio +import json +import logging +import random +import typing as t +from collections import deque +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import discord +import discord.abc +from discord.ext import commands + +from bot import constants +from bot.bot import Bot +from bot.utils import RedisCache +from bot.utils.checks import with_role_check +from bot.utils.scheduling import Scheduler + +log = logging.getLogger(__name__) + +ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" +MAX_CHANNELS_PER_CATEGORY = 50 +EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, constants.Channels.cooldown) + +HELP_CHANNEL_TOPIC = """ +This is a Python help channel. You can claim your own help channel in the Python Help: Available category. +""" + +AVAILABLE_MSG = f""" +This help channel is now **available**, which means that you can claim it by simply typing your \ +question into it. Once claimed, the channel will move into the **Python Help: Occupied** category, \ +and will be yours until it has been inactive for {constants.HelpChannels.idle_minutes} minutes or \ +is closed manually with `!close`. When that happens, it will be set to **dormant** and moved into \ +the **Help: Dormant** category. + +Try to write the best question you can by providing a detailed description and telling us what \ +you've tried already. For more information on asking a good question, \ +check out our guide on [asking good questions]({ASKING_GUIDE_URL}). +""" + +DORMANT_MSG = f""" +This help channel has been marked as **dormant**, and has been moved into the **Help: Dormant** \ +category at the bottom of the channel list. It is no longer possible to send messages in this \ +channel until it becomes available again. + +If your question wasn't answered yet, you can claim a new help channel from the \ +**Help: Available** category by simply asking your question again. Consider rephrasing the \ +question to maximize your chance of getting a good answer. If you're not sure how, have a look \ +through our guide for [asking a good question]({ASKING_GUIDE_URL}). +""" + +CoroutineFunc = t.Callable[..., t.Coroutine] + + +class HelpChannels(commands.Cog): + """ + Manage the help channel system of the guild. + + The system is based on a 3-category system: + + Available Category + + * Contains channels which are ready to be occupied by someone who needs help + * Will always contain `constants.HelpChannels.max_available` channels; refilled automatically + from the pool of dormant channels + * Prioritise using the channels which have been dormant for the longest amount of time + * If there are no more dormant channels, the bot will automatically create a new one + * If there are no dormant channels to move, helpers will be notified (see `notify()`) + * When a channel becomes available, the dormant embed will be edited to show `AVAILABLE_MSG` + * User can only claim a channel at an interval `constants.HelpChannels.claim_minutes` + * To keep track of cooldowns, user which claimed a channel will have a temporary role + + In Use Category + + * Contains all channels which are occupied by someone needing help + * Channel moves to dormant category after `constants.HelpChannels.idle_minutes` of being idle + * Command can prematurely mark a channel as dormant + * Channel claimant is allowed to use the command + * Allowed roles for the command are configurable with `constants.HelpChannels.cmd_whitelist` + * When a channel becomes dormant, an embed with `DORMANT_MSG` will be sent + + Dormant Category + + * Contains channels which aren't in use + * Channels are used to refill the Available category + + Help channels are named after the chemical elements in `bot/resources/elements.json`. + """ + + # This cache tracks which channels are claimed by which members. + # RedisCache[discord.TextChannel.id, t.Union[discord.User.id, discord.Member.id]] + help_channel_claimants = RedisCache() + + # This cache maps a help channel to whether it has had any + # activity other than the original claimant. True being no other + # activity and False being other activity. + # RedisCache[discord.TextChannel.id, bool] + unanswered = RedisCache() + + # This dictionary maps a help channel to the time it was claimed + # RedisCache[discord.TextChannel.id, UtcPosixTimestamp] + claim_times = RedisCache() + + # This cache maps a help channel to original question message in same channel. + # RedisCache[discord.TextChannel.id, discord.Message.id] + question_messages = RedisCache() + + def __init__(self, bot: Bot): + self.bot = bot + self.scheduler = Scheduler(self.__class__.__name__) + + # Categories + self.available_category: discord.CategoryChannel = None + self.in_use_category: discord.CategoryChannel = None + self.dormant_category: discord.CategoryChannel = None + + # Queues + self.channel_queue: asyncio.Queue[discord.TextChannel] = None + self.name_queue: t.Deque[str] = None + + self.name_positions = self.get_names() + self.last_notification: t.Optional[datetime] = None + + # Asyncio stuff + self.queue_tasks: t.List[asyncio.Task] = [] + self.ready = asyncio.Event() + self.on_message_lock = asyncio.Lock() + self.init_task = self.bot.loop.create_task(self.init_cog()) + + def cog_unload(self) -> None: + """Cancel the init task and scheduled tasks when the cog unloads.""" + log.trace("Cog unload: cancelling the init_cog task") + self.init_task.cancel() + + log.trace("Cog unload: cancelling the channel queue tasks") + for task in self.queue_tasks: + task.cancel() + + self.scheduler.cancel_all() + + def create_channel_queue(self) -> asyncio.Queue: + """ + Return a queue of dormant channels to use for getting the next available channel. + + The channels are added to the queue in a random order. + """ + log.trace("Creating the channel queue.") + + channels = list(self.get_category_channels(self.dormant_category)) + random.shuffle(channels) + + log.trace("Populating the channel queue with channels.") + queue = asyncio.Queue() + for channel in channels: + queue.put_nowait(channel) + + return queue + + async def create_dormant(self) -> t.Optional[discord.TextChannel]: + """ + Create and return a new channel in the Dormant category. + + The new channel will sync its permission overwrites with the category. + + Return None if no more channel names are available. + """ + log.trace("Getting a name for a new dormant channel.") + + try: + name = self.name_queue.popleft() + except IndexError: + log.debug("No more names available for new dormant channels.") + return None + + log.debug(f"Creating a new dormant channel named {name}.") + return await self.dormant_category.create_text_channel(name, topic=HELP_CHANNEL_TOPIC) + + def create_name_queue(self) -> deque: + """Return a queue of element names to use for creating new channels.""" + log.trace("Creating the chemical element name queue.") + + used_names = self.get_used_names() + + log.trace("Determining the available names.") + available_names = (name for name in self.name_positions if name not in used_names) + + log.trace("Populating the name queue with names.") + return deque(available_names) + + async def dormant_check(self, ctx: commands.Context) -> bool: + """Return True if the user is the help channel claimant or passes the role check.""" + if await self.help_channel_claimants.get(ctx.channel.id) == ctx.author.id: + log.trace(f"{ctx.author} is the help channel claimant, passing the check for dormant.") + self.bot.stats.incr("help.dormant_invoke.claimant") + return True + + log.trace(f"{ctx.author} is not the help channel claimant, checking roles.") + role_check = with_role_check(ctx, *constants.HelpChannels.cmd_whitelist) + + if role_check: + self.bot.stats.incr("help.dormant_invoke.staff") + + return role_check + + @commands.command(name="close", aliases=["dormant", "solved"], enabled=False) + async def close_command(self, ctx: commands.Context) -> None: + """ + Make the current in-use help channel dormant. + + Make the channel dormant if the user passes the `dormant_check`, + delete the message that invoked this, + and reset the send permissions cooldown for the user who started the session. + """ + log.trace("close command invoked; checking if the channel is in-use.") + if ctx.channel.category == self.in_use_category: + if await self.dormant_check(ctx): + await self.remove_cooldown_role(ctx.author) + + # Ignore missing task when cooldown has passed but the channel still isn't dormant. + if ctx.author.id in self.scheduler: + self.scheduler.cancel(ctx.author.id) + + await self.move_to_dormant(ctx.channel, "command") + self.scheduler.cancel(ctx.channel.id) + else: + log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") + + async def get_available_candidate(self) -> discord.TextChannel: + """ + Return a dormant channel to turn into an available channel. + + If no channel is available, wait indefinitely until one becomes available. + """ + log.trace("Getting an available channel candidate.") + + try: + channel = self.channel_queue.get_nowait() + except asyncio.QueueEmpty: + log.info("No candidate channels in the queue; creating a new channel.") + channel = await self.create_dormant() + + if not channel: + log.info("Couldn't create a candidate channel; waiting to get one from the queue.") + await self.notify() + channel = await self.wait_for_dormant_channel() + + return channel + + @staticmethod + def get_clean_channel_name(channel: discord.TextChannel) -> str: + """Return a clean channel name without status emojis prefix.""" + prefix = constants.HelpChannels.name_prefix + try: + # Try to remove the status prefix using the index of the channel prefix + name = channel.name[channel.name.index(prefix):] + log.trace(f"The clean name for `{channel}` is `{name}`") + except ValueError: + # If, for some reason, the channel name does not contain "help-" fall back gracefully + log.info(f"Can't get clean name because `{channel}` isn't prefixed by `{prefix}`.") + name = channel.name + + return name + + @staticmethod + def is_excluded_channel(channel: discord.abc.GuildChannel) -> bool: + """Check if a channel should be excluded from the help channel system.""" + return not isinstance(channel, discord.TextChannel) or channel.id in EXCLUDED_CHANNELS + + def get_category_channels(self, category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: + """Yield the text channels of the `category` in an unsorted manner.""" + log.trace(f"Getting text channels in the category '{category}' ({category.id}).") + + # This is faster than using category.channels because the latter sorts them. + for channel in self.bot.get_guild(constants.Guild.id).channels: + if channel.category_id == category.id and not self.is_excluded_channel(channel): + yield channel + + async def get_in_use_time(self, channel_id: int) -> t.Optional[timedelta]: + """Return the duration `channel_id` has been in use. Return None if it's not in use.""" + log.trace(f"Calculating in use time for channel {channel_id}.") + + claimed_timestamp = await self.claim_times.get(channel_id) + if claimed_timestamp: + claimed = datetime.utcfromtimestamp(claimed_timestamp) + return datetime.utcnow() - claimed + + @staticmethod + def get_names() -> t.List[str]: + """ + Return a truncated list of prefixed element names. + + The amount of names is configured with `HelpChannels.max_total_channels`. + The prefix is configured with `HelpChannels.name_prefix`. + """ + count = constants.HelpChannels.max_total_channels + prefix = constants.HelpChannels.name_prefix + + log.trace(f"Getting the first {count} element names from JSON.") + + with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file: + all_names = json.load(elements_file) + + if prefix: + return [prefix + name for name in all_names[:count]] + else: + return all_names[:count] + + def get_used_names(self) -> t.Set[str]: + """Return channel names which are already being used.""" + log.trace("Getting channel names which are already being used.") + + names = set() + for cat in (self.available_category, self.in_use_category, self.dormant_category): + for channel in self.get_category_channels(cat): + names.add(self.get_clean_channel_name(channel)) + + if len(names) > MAX_CHANNELS_PER_CATEGORY: + log.warning( + f"Too many help channels ({len(names)}) already exist! " + f"Discord only supports {MAX_CHANNELS_PER_CATEGORY} in a category." + ) + + log.trace(f"Got {len(names)} used names: {names}") + return names + + @classmethod + async def get_idle_time(cls, channel: discord.TextChannel) -> t.Optional[int]: + """ + Return the time elapsed, in seconds, since the last message sent in the `channel`. + + Return None if the channel has no messages. + """ + log.trace(f"Getting the idle time for #{channel} ({channel.id}).") + + msg = await cls.get_last_message(channel) + if not msg: + log.debug(f"No idle time available; #{channel} ({channel.id}) has no messages.") + return None + + idle_time = (datetime.utcnow() - msg.created_at).seconds + + log.trace(f"#{channel} ({channel.id}) has been idle for {idle_time} seconds.") + return idle_time + + @staticmethod + async def get_last_message(channel: discord.TextChannel) -> t.Optional[discord.Message]: + """Return the last message sent in the channel or None if no messages exist.""" + log.trace(f"Getting the last message in #{channel} ({channel.id}).") + + try: + return await channel.history(limit=1).next() # noqa: B305 + except discord.NoMoreItems: + log.debug(f"No last message available; #{channel} ({channel.id}) has no messages.") + return None + + async def init_available(self) -> None: + """Initialise the Available category with channels.""" + log.trace("Initialising the Available category with channels.") + + channels = list(self.get_category_channels(self.available_category)) + missing = constants.HelpChannels.max_available - len(channels) + + # If we've got less than `max_available` channel available, we should add some. + if missing > 0: + log.trace(f"Moving {missing} missing channels to the Available category.") + for _ in range(missing): + await self.move_to_available() + + # If for some reason we have more than `max_available` channels available, + # we should move the superfluous ones over to dormant. + elif missing < 0: + log.trace(f"Moving {abs(missing)} superfluous available channels over to the Dormant category.") + for channel in channels[:abs(missing)]: + await self.move_to_dormant(channel, "auto") + + async def init_categories(self) -> None: + """Get the help category objects. Remove the cog if retrieval fails.""" + log.trace("Getting the CategoryChannel objects for the help categories.") + + try: + self.available_category = await self.try_get_channel( + constants.Categories.help_available + ) + self.in_use_category = await self.try_get_channel(constants.Categories.help_in_use) + self.dormant_category = await self.try_get_channel(constants.Categories.help_dormant) + except discord.HTTPException: + log.exception("Failed to get a category; cog will be removed") + self.bot.remove_cog(self.qualified_name) + + async def init_cog(self) -> None: + """Initialise the help channel system.""" + log.trace("Waiting for the guild to be available before initialisation.") + await self.bot.wait_until_guild_available() + + log.trace("Initialising the cog.") + await self.init_categories() + await self.check_cooldowns() + + self.channel_queue = self.create_channel_queue() + self.name_queue = self.create_name_queue() + + log.trace("Moving or rescheduling in-use channels.") + for channel in self.get_category_channels(self.in_use_category): + await self.move_idle_channel(channel, has_task=False) + + # Prevent the command from being used until ready. + # The ready event wasn't used because channels could change categories between the time + # the command is invoked and the cog is ready (e.g. if move_idle_channel wasn't called yet). + # This may confuse users. So would potentially long delays for the cog to become ready. + self.close_command.enabled = True + + await self.init_available() + + log.info("Cog is ready!") + self.ready.set() + + self.report_stats() + + def report_stats(self) -> None: + """Report the channel count stats.""" + total_in_use = sum(1 for _ in self.get_category_channels(self.in_use_category)) + total_available = sum(1 for _ in self.get_category_channels(self.available_category)) + total_dormant = sum(1 for _ in self.get_category_channels(self.dormant_category)) + + self.bot.stats.gauge("help.total.in_use", total_in_use) + self.bot.stats.gauge("help.total.available", total_available) + self.bot.stats.gauge("help.total.dormant", total_dormant) + + @staticmethod + def is_claimant(member: discord.Member) -> bool: + """Return True if `member` has the 'Help Cooldown' role.""" + return any(constants.Roles.help_cooldown == role.id for role in member.roles) + + def match_bot_embed(self, message: t.Optional[discord.Message], description: str) -> bool: + """Return `True` if the bot's `message`'s embed description matches `description`.""" + if not message or not message.embeds: + return False + + bot_msg_desc = message.embeds[0].description + if bot_msg_desc is discord.Embed.Empty: + log.trace("Last message was a bot embed but it was empty.") + return False + return message.author == self.bot.user and bot_msg_desc.strip() == description.strip() + + @staticmethod + def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: + """Return True if `channel` is within a category with `category_id`.""" + actual_category = getattr(channel, "category", None) + return actual_category is not None and actual_category.id == category_id + + async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None: + """ + Make the `channel` dormant if idle or schedule the move if still active. + + If `has_task` is True and rescheduling is required, the extant task to make the channel + dormant will first be cancelled. + """ + log.trace(f"Handling in-use channel #{channel} ({channel.id}).") + + if not await self.is_empty(channel): + idle_seconds = constants.HelpChannels.idle_minutes * 60 + else: + idle_seconds = constants.HelpChannels.deleted_idle_minutes * 60 + + time_elapsed = await self.get_idle_time(channel) + + if time_elapsed is None or time_elapsed >= idle_seconds: + log.info( + f"#{channel} ({channel.id}) is idle longer than {idle_seconds} seconds " + f"and will be made dormant." + ) + + await self.move_to_dormant(channel, "auto") + else: + # Cancel the existing task, if any. + if has_task: + self.scheduler.cancel(channel.id) + + delay = idle_seconds - time_elapsed + log.info( + f"#{channel} ({channel.id}) is still active; " + f"scheduling it to be moved after {delay} seconds." + ) + + self.scheduler.schedule_later(delay, channel.id, self.move_idle_channel(channel)) + + async def move_to_bottom_position(self, channel: discord.TextChannel, category_id: int, **options) -> None: + """ + Move the `channel` to the bottom position of `category` and edit channel attributes. + + To ensure "stable sorting", we use the `bulk_channel_update` endpoint and provide the current + positions of the other channels in the category as-is. This should make sure that the channel + really ends up at the bottom of the category. + + If `options` are provided, the channel will be edited after the move is completed. This is the + same order of operations that `discord.TextChannel.edit` uses. For information on available + options, see the documention on `discord.TextChannel.edit`. While possible, position-related + options should be avoided, as it may interfere with the category move we perform. + """ + # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had. + category = await self.try_get_channel(category_id) + + payload = [{"id": c.id, "position": c.position} for c in category.channels] + + # Calculate the bottom position based on the current highest position in the category. If the + # category is currently empty, we simply use the current position of the channel to avoid making + # unnecessary changes to positions in the guild. + bottom_position = payload[-1]["position"] + 1 if payload else channel.position + + payload.append( + { + "id": channel.id, + "position": bottom_position, + "parent_id": category.id, + "lock_permissions": True, + } + ) + + # We use d.py's method to ensure our request is processed by d.py's rate limit manager + await self.bot.http.bulk_channel_update(category.guild.id, payload) + + # Now that the channel is moved, we can edit the other attributes + if options: + await channel.edit(**options) + + async def move_to_available(self) -> None: + """Make a channel available.""" + log.trace("Making a channel available.") + + channel = await self.get_available_candidate() + log.info(f"Making #{channel} ({channel.id}) available.") + + await self.send_available_message(channel) + + log.trace(f"Moving #{channel} ({channel.id}) to the Available category.") + + await self.move_to_bottom_position( + channel=channel, + category_id=constants.Categories.help_available, + ) + + self.report_stats() + + async def move_to_dormant(self, channel: discord.TextChannel, caller: str) -> None: + """ + Make the `channel` dormant. + + A caller argument is provided for metrics. + """ + log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") + + await self.help_channel_claimants.delete(channel.id) + await self.move_to_bottom_position( + channel=channel, + category_id=constants.Categories.help_dormant, + ) + + self.bot.stats.incr(f"help.dormant_calls.{caller}") + + in_use_time = await self.get_in_use_time(channel.id) + if in_use_time: + self.bot.stats.timing("help.in_use_time", in_use_time) + + unanswered = await self.unanswered.get(channel.id) + if unanswered: + self.bot.stats.incr("help.sessions.unanswered") + elif unanswered is not None: + self.bot.stats.incr("help.sessions.answered") + + log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.") + log.trace(f"Sending dormant message for #{channel} ({channel.id}).") + embed = discord.Embed(description=DORMANT_MSG) + await channel.send(embed=embed) + + await self.unpin(channel) + + log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.") + self.channel_queue.put_nowait(channel) + self.report_stats() + + async def move_to_in_use(self, channel: discord.TextChannel) -> None: + """Make a channel in-use and schedule it to be made dormant.""" + log.info(f"Moving #{channel} ({channel.id}) to the In Use category.") + + await self.move_to_bottom_position( + channel=channel, + category_id=constants.Categories.help_in_use, + ) + + timeout = constants.HelpChannels.idle_minutes * 60 + + log.trace(f"Scheduling #{channel} ({channel.id}) to become dormant in {timeout} sec.") + self.scheduler.schedule_later(timeout, channel.id, self.move_idle_channel(channel)) + self.report_stats() + + async def notify(self) -> None: + """ + Send a message notifying about a lack of available help channels. + + Configuration: + + * `HelpChannels.notify` - toggle notifications + * `HelpChannels.notify_channel` - destination channel for notifications + * `HelpChannels.notify_minutes` - minimum interval between notifications + * `HelpChannels.notify_roles` - roles mentioned in notifications + """ + if not constants.HelpChannels.notify: + return + + log.trace("Notifying about lack of channels.") + + if self.last_notification: + elapsed = (datetime.utcnow() - self.last_notification).seconds + minimum_interval = constants.HelpChannels.notify_minutes * 60 + should_send = elapsed >= minimum_interval + else: + should_send = True + + if not should_send: + log.trace("Notification not sent because it's too recent since the previous one.") + return + + try: + log.trace("Sending notification message.") + + channel = self.bot.get_channel(constants.HelpChannels.notify_channel) + mentions = " ".join(f"<@&{role}>" for role in constants.HelpChannels.notify_roles) + allowed_roles = [discord.Object(id_) for id_ in constants.HelpChannels.notify_roles] + + message = await channel.send( + f"{mentions} A new available help channel is needed but there " + f"are no more dormant ones. Consider freeing up some in-use channels manually by " + f"using the `{constants.Bot.prefix}dormant` command within the channels.", + allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) + ) + + self.bot.stats.incr("help.out_of_channel_alerts") + + self.last_notification = message.created_at + except Exception: + # Handle it here cause this feature isn't critical for the functionality of the system. + log.exception("Failed to send notification about lack of dormant channels!") + + async def check_for_answer(self, message: discord.Message) -> None: + """Checks for whether new content in a help channel comes from non-claimants.""" + channel = message.channel + + # Confirm the channel is an in use help channel + if self.is_in_category(channel, constants.Categories.help_in_use): + log.trace(f"Checking if #{channel} ({channel.id}) has been answered.") + + # Check if there is an entry in unanswered + if await self.unanswered.contains(channel.id): + claimant_id = await self.help_channel_claimants.get(channel.id) + if not claimant_id: + # The mapping for this channel doesn't exist, we can't do anything. + return + + # Check the message did not come from the claimant + if claimant_id != message.author.id: + # Mark the channel as answered + await self.unanswered.set(channel.id, False) + + @commands.Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """Move an available channel to the In Use category and replace it with a dormant one.""" + if message.author.bot: + return # Ignore messages sent by bots. + + channel = message.channel + + await self.check_for_answer(message) + + if not self.is_in_category(channel, constants.Categories.help_available) or self.is_excluded_channel(channel): + return # Ignore messages outside the Available category or in excluded channels. + + log.trace("Waiting for the cog to be ready before processing messages.") + await self.ready.wait() + + log.trace("Acquiring lock to prevent a channel from being processed twice...") + async with self.on_message_lock: + log.trace(f"on_message lock acquired for {message.id}.") + + if not self.is_in_category(channel, constants.Categories.help_available): + log.debug( + f"Message {message.id} will not make #{channel} ({channel.id}) in-use " + f"because another message in the channel already triggered that." + ) + return + + log.info(f"Channel #{channel} was claimed by `{message.author.id}`.") + await self.move_to_in_use(channel) + await self.revoke_send_permissions(message.author) + + await self.pin(message) + + # Add user with channel for dormant check. + await self.help_channel_claimants.set(channel.id, message.author.id) + + self.bot.stats.incr("help.claimed") + + # Must use a timezone-aware datetime to ensure a correct POSIX timestamp. + timestamp = datetime.now(timezone.utc).timestamp() + await self.claim_times.set(channel.id, timestamp) + + await self.unanswered.set(channel.id, True) + + log.trace(f"Releasing on_message lock for {message.id}.") + + # Move a dormant channel to the Available category to fill in the gap. + # This is done last and outside the lock because it may wait indefinitely for a channel to + # be put in the queue. + await self.move_to_available() + + @commands.Cog.listener() + async def on_message_delete(self, msg: discord.Message) -> None: + """ + Reschedule an in-use channel to become dormant sooner if the channel is empty. + + The new time for the dormant task is configured with `HelpChannels.deleted_idle_minutes`. + """ + if not self.is_in_category(msg.channel, constants.Categories.help_in_use): + return + + if not await self.is_empty(msg.channel): + return + + log.info(f"Claimant of #{msg.channel} ({msg.author}) deleted message, channel is empty now. Rescheduling task.") + + # Cancel existing dormant task before scheduling new. + self.scheduler.cancel(msg.channel.id) + + delay = constants.HelpChannels.deleted_idle_minutes * 60 + self.scheduler.schedule_later(delay, msg.channel.id, self.move_idle_channel(msg.channel)) + + async def is_empty(self, channel: discord.TextChannel) -> bool: + """Return True if there's an AVAILABLE_MSG and the messages leading up are bot messages.""" + log.trace(f"Checking if #{channel} ({channel.id}) is empty.") + + # A limit of 100 results in a single API call. + # If AVAILABLE_MSG isn't found within 100 messages, then assume the channel is not empty. + # Not gonna do an extensive search for it cause it's too expensive. + async for msg in channel.history(limit=100): + if not msg.author.bot: + log.trace(f"#{channel} ({channel.id}) has a non-bot message.") + return False + + if self.match_bot_embed(msg, AVAILABLE_MSG): + log.trace(f"#{channel} ({channel.id}) has the available message embed.") + return True + + return False + + async def check_cooldowns(self) -> None: + """Remove expired cooldowns and re-schedule active ones.""" + log.trace("Checking all cooldowns to remove or re-schedule them.") + guild = self.bot.get_guild(constants.Guild.id) + cooldown = constants.HelpChannels.claim_minutes * 60 + + for channel_id, member_id in await self.help_channel_claimants.items(): + member = guild.get_member(member_id) + if not member: + continue # Member probably left the guild. + + in_use_time = await self.get_in_use_time(channel_id) + + if not in_use_time or in_use_time.seconds > cooldown: + # Remove the role if no claim time could be retrieved or if the cooldown expired. + # Since the channel is in the claimants cache, it is definitely strange for a time + # to not exist. However, it isn't a reason to keep the user stuck with a cooldown. + await self.remove_cooldown_role(member) + else: + # The member is still on a cooldown; re-schedule it for the remaining time. + delay = cooldown - in_use_time.seconds + self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member)) + + async def add_cooldown_role(self, member: discord.Member) -> None: + """Add the help cooldown role to `member`.""" + log.trace(f"Adding cooldown role for {member} ({member.id}).") + await self._change_cooldown_role(member, member.add_roles) + + async def remove_cooldown_role(self, member: discord.Member) -> None: + """Remove the help cooldown role from `member`.""" + log.trace(f"Removing cooldown role for {member} ({member.id}).") + await self._change_cooldown_role(member, member.remove_roles) + + async def _change_cooldown_role(self, member: discord.Member, coro_func: CoroutineFunc) -> None: + """ + Change `member`'s cooldown role via awaiting `coro_func` and handle errors. + + `coro_func` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`. + """ + guild = self.bot.get_guild(constants.Guild.id) + role = guild.get_role(constants.Roles.help_cooldown) + if role is None: + log.warning(f"Help cooldown role ({constants.Roles.help_cooldown}) could not be found!") + return + + try: + await coro_func(role) + except discord.NotFound: + log.debug(f"Failed to change role for {member} ({member.id}): member not found") + except discord.Forbidden: + log.debug( + f"Forbidden to change role for {member} ({member.id}); " + f"possibly due to role hierarchy" + ) + except discord.HTTPException as e: + log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}") + + async def revoke_send_permissions(self, member: discord.Member) -> None: + """ + Disallow `member` to send messages in the Available category for a certain time. + + The time until permissions are reinstated can be configured with + `HelpChannels.claim_minutes`. + """ + log.trace( + f"Revoking {member}'s ({member.id}) send message permissions in the Available category." + ) + + await self.add_cooldown_role(member) + + # Cancel the existing task, if any. + # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner). + if member.id in self.scheduler: + self.scheduler.cancel(member.id) + + delay = constants.HelpChannels.claim_minutes * 60 + self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member)) + + async def send_available_message(self, channel: discord.TextChannel) -> None: + """Send the available message by editing a dormant message or sending a new message.""" + channel_info = f"#{channel} ({channel.id})" + log.trace(f"Sending available message in {channel_info}.") + + embed = discord.Embed(description=AVAILABLE_MSG) + + msg = await self.get_last_message(channel) + if self.match_bot_embed(msg, DORMANT_MSG): + log.trace(f"Found dormant message {msg.id} in {channel_info}; editing it.") + await msg.edit(embed=embed) + else: + log.trace(f"Dormant message not found in {channel_info}; sending a new message.") + await channel.send(embed=embed) + + async def try_get_channel(self, channel_id: int) -> discord.abc.GuildChannel: + """Attempt to get or fetch a channel and return it.""" + log.trace(f"Getting the channel {channel_id}.") + + channel = self.bot.get_channel(channel_id) + if not channel: + log.debug(f"Channel {channel_id} is not in cache; fetching from API.") + channel = await self.bot.fetch_channel(channel_id) + + log.trace(f"Channel #{channel} ({channel_id}) retrieved.") + return channel + + async def pin_wrapper(self, msg_id: int, channel: discord.TextChannel, *, pin: bool) -> bool: + """ + Pin message `msg_id` in `channel` if `pin` is True or unpin if it's False. + + Return True if successful and False otherwise. + """ + channel_str = f"#{channel} ({channel.id})" + if pin: + func = self.bot.http.pin_message + verb = "pin" + else: + func = self.bot.http.unpin_message + verb = "unpin" + + try: + await func(channel.id, msg_id) + except discord.HTTPException as e: + if e.code == 10008: + log.debug(f"Message {msg_id} in {channel_str} doesn't exist; can't {verb}.") + else: + log.exception( + f"Error {verb}ning message {msg_id} in {channel_str}: {e.status} ({e.code})" + ) + return False + else: + log.trace(f"{verb.capitalize()}ned message {msg_id} in {channel_str}.") + return True + + async def pin(self, message: discord.Message) -> None: + """Pin an initial question `message` and store it in a cache.""" + if await self.pin_wrapper(message.id, message.channel, pin=True): + await self.question_messages.set(message.channel.id, message.id) + + async def unpin(self, channel: discord.TextChannel) -> None: + """Unpin the initial question message sent in `channel`.""" + msg_id = await self.question_messages.pop(channel.id) + if msg_id is None: + log.debug(f"#{channel} ({channel.id}) doesn't have a message pinned.") + else: + await self.pin_wrapper(msg_id, channel, pin=False) + + async def wait_for_dormant_channel(self) -> discord.TextChannel: + """Wait for a dormant channel to become available in the queue and return it.""" + log.trace("Waiting for a dormant channel.") + + task = asyncio.create_task(self.channel_queue.get()) + self.queue_tasks.append(task) + channel = await task + + log.trace(f"Channel #{channel} ({channel.id}) finally retrieved from the queue.") + self.queue_tasks.remove(task) + + return channel + + +def validate_config() -> None: + """Raise a ValueError if the cog's config is invalid.""" + log.trace("Validating config.") + total = constants.HelpChannels.max_total_channels + available = constants.HelpChannels.max_available + + if total == 0 or available == 0: + raise ValueError("max_total_channels and max_available and must be greater than 0.") + + if total < available: + raise ValueError( + f"max_total_channels ({total}) must be greater than or equal to max_available " + f"({available})." + ) + + if total > MAX_CHANNELS_PER_CATEGORY: + raise ValueError( + f"max_total_channels ({total}) must be less than or equal to " + f"{MAX_CHANNELS_PER_CATEGORY} due to Discord's limit on channels per category." + ) + + +def setup(bot: Bot) -> None: + """Load the HelpChannels cog.""" + try: + validate_config() + except ValueError as e: + log.error(f"HelpChannels cog will not be loaded due to misconfiguration: {e}") + else: + bot.add_cog(HelpChannels(bot)) diff --git a/bot/exts/info/__init__.py b/bot/exts/info/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bot/exts/info/doc.py b/bot/exts/info/doc.py new file mode 100644 index 000000000..204cffb37 --- /dev/null +++ b/bot/exts/info/doc.py @@ -0,0 +1,511 @@ +import asyncio +import functools +import logging +import re +import textwrap +from collections import OrderedDict +from contextlib import suppress +from types import SimpleNamespace +from typing import Any, Callable, Optional, Tuple + +import discord +from bs4 import BeautifulSoup +from bs4.element import PageElement, Tag +from discord.errors import NotFound +from discord.ext import commands +from markdownify import MarkdownConverter +from requests import ConnectTimeout, ConnectionError, HTTPError +from sphinx.ext import intersphinx +from urllib3.exceptions import ProtocolError + +from bot.bot import Bot +from bot.constants import MODERATION_ROLES, RedirectOutput +from bot.converters import ValidPythonIdentifier, ValidURL +from bot.decorators import with_role +from bot.pagination import LinePaginator + + +log = logging.getLogger(__name__) +logging.getLogger('urllib3').setLevel(logging.WARNING) + +# Since Intersphinx is intended to be used with Sphinx, +# we need to mock its configuration. +SPHINX_MOCK_APP = SimpleNamespace( + config=SimpleNamespace( + intersphinx_timeout=3, + tls_verify=True, + user_agent="python3:python-discord/bot:1.0.0" + ) +) + +NO_OVERRIDE_GROUPS = ( + "2to3fixer", + "token", + "label", + "pdbcommand", + "term", +) +NO_OVERRIDE_PACKAGES = ( + "python", +) + +SEARCH_END_TAG_ATTRS = ( + "data", + "function", + "class", + "exception", + "seealso", + "section", + "rubric", + "sphinxsidebar", +) +UNWANTED_SIGNATURE_SYMBOLS_RE = re.compile(r"\[source]|\\\\|¶") +WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)") + +FAILED_REQUEST_RETRY_AMOUNT = 3 +NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay + + +def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable: + """ + LRU cache implementation for coroutines. + + Once the cache exceeds the maximum size, keys are deleted in FIFO order. + + An offset may be optionally provided to be applied to the coroutine's arguments when creating the cache key. + """ + # Assign the cache to the function itself so we can clear it from outside. + async_cache.cache = OrderedDict() + + def decorator(function: Callable) -> Callable: + """Define the async_cache decorator.""" + @functools.wraps(function) + async def wrapper(*args) -> Any: + """Decorator wrapper for the caching logic.""" + key = ':'.join(args[arg_offset:]) + + value = async_cache.cache.get(key) + if value is None: + if len(async_cache.cache) > max_size: + async_cache.cache.popitem(last=False) + + async_cache.cache[key] = await function(*args) + return async_cache.cache[key] + return wrapper + return decorator + + +class DocMarkdownConverter(MarkdownConverter): + """Subclass markdownify's MarkdownCoverter to provide custom conversion methods.""" + + def convert_code(self, el: PageElement, text: str) -> str: + """Undo `markdownify`s underscore escaping.""" + return f"`{text}`".replace('\\', '') + + def convert_pre(self, el: PageElement, text: str) -> str: + """Wrap any codeblocks in `py` for syntax highlighting.""" + code = ''.join(el.strings) + return f"```py\n{code}```" + + +def markdownify(html: str) -> DocMarkdownConverter: + """Create a DocMarkdownConverter object from the input html.""" + return DocMarkdownConverter(bullets='•').convert(html) + + +class InventoryURL(commands.Converter): + """ + Represents an Intersphinx inventory URL. + + This converter checks whether intersphinx accepts the given inventory URL, and raises + `BadArgument` if that is not the case. + + Otherwise, it simply passes through the given URL. + """ + + @staticmethod + async def convert(ctx: commands.Context, url: str) -> str: + """Convert url to Intersphinx inventory URL.""" + try: + intersphinx.fetch_inventory(SPHINX_MOCK_APP, '', url) + except AttributeError: + raise commands.BadArgument(f"Failed to fetch Intersphinx inventory from URL `{url}`.") + except ConnectionError: + if url.startswith('https'): + raise commands.BadArgument( + f"Cannot establish a connection to `{url}`. Does it support HTTPS?" + ) + raise commands.BadArgument(f"Cannot connect to host with URL `{url}`.") + except ValueError: + raise commands.BadArgument( + f"Failed to read Intersphinx inventory from URL `{url}`. " + "Are you sure that it's a valid inventory file?" + ) + return url + + +class Doc(commands.Cog): + """A set of commands for querying & displaying documentation.""" + + def __init__(self, bot: Bot): + self.base_urls = {} + self.bot = bot + self.inventories = {} + self.renamed_symbols = set() + + self.bot.loop.create_task(self.init_refresh_inventory()) + + async def init_refresh_inventory(self) -> None: + """Refresh documentation inventory on cog initialization.""" + await self.bot.wait_until_guild_available() + await self.refresh_inventory() + + async def update_single( + self, package_name: str, base_url: str, inventory_url: str + ) -> None: + """ + Rebuild the inventory for a single package. + + Where: + * `package_name` is the package name to use, appears in the log + * `base_url` is the root documentation URL for the specified package, used to build + absolute paths that link to specific symbols + * `inventory_url` is the absolute URL to the intersphinx inventory, fetched by running + `intersphinx.fetch_inventory` in an executor on the bot's event loop + """ + self.base_urls[package_name] = base_url + + package = await self._fetch_inventory(inventory_url) + if not package: + return None + + for group, value in package.items(): + for symbol, (package_name, _version, relative_doc_url, _) in value.items(): + absolute_doc_url = base_url + relative_doc_url + + if symbol in self.inventories: + group_name = group.split(":")[1] + symbol_base_url = self.inventories[symbol].split("/", 3)[2] + if ( + group_name in NO_OVERRIDE_GROUPS + or any(package in symbol_base_url for package in NO_OVERRIDE_PACKAGES) + ): + + symbol = f"{group_name}.{symbol}" + # If renamed `symbol` already exists, add library name in front to differentiate between them. + if symbol in self.renamed_symbols: + # Split `package_name` because of packages like Pillow that have spaces in them. + symbol = f"{package_name.split()[0]}.{symbol}" + + self.inventories[symbol] = absolute_doc_url + self.renamed_symbols.add(symbol) + continue + + self.inventories[symbol] = absolute_doc_url + + log.trace(f"Fetched inventory for {package_name}.") + + async def refresh_inventory(self) -> None: + """Refresh internal documentation inventory.""" + log.debug("Refreshing documentation inventory...") + + # Clear the old base URLS and inventories to ensure + # that we start from a fresh local dataset. + # Also, reset the cache used for fetching documentation. + self.base_urls.clear() + self.inventories.clear() + self.renamed_symbols.clear() + async_cache.cache = OrderedDict() + + # Run all coroutines concurrently - since each of them performs a HTTP + # request, this speeds up fetching the inventory data heavily. + coros = [ + self.update_single( + package["package"], package["base_url"], package["inventory_url"] + ) for package in await self.bot.api_client.get('bot/documentation-links') + ] + await asyncio.gather(*coros) + + async def get_symbol_html(self, symbol: str) -> Optional[Tuple[list, str]]: + """ + Given a Python symbol, return its signature and description. + + The first tuple element is the signature of the given symbol as a markup-free string, and + the second tuple element is the description of the given symbol with HTML markup included. + + If the given symbol is a module, returns a tuple `(None, str)` + else if the symbol could not be found, returns `None`. + """ + url = self.inventories.get(symbol) + if url is None: + return None + + async with self.bot.http_session.get(url) as response: + html = await response.text(encoding='utf-8') + + # Find the signature header and parse the relevant parts. + symbol_id = url.split('#')[-1] + soup = BeautifulSoup(html, 'lxml') + symbol_heading = soup.find(id=symbol_id) + search_html = str(soup) + + if symbol_heading is None: + return None + + if symbol_id == f"module-{symbol}": + # Get page content from the module headerlink to the + # first tag that has its class in `SEARCH_END_TAG_ATTRS` + start_tag = symbol_heading.find("a", attrs={"class": "headerlink"}) + if start_tag is None: + return [], "" + + end_tag = start_tag.find_next(self._match_end_tag) + if end_tag is None: + return [], "" + + description_start_index = search_html.find(str(start_tag.parent)) + len(str(start_tag.parent)) + description_end_index = search_html.find(str(end_tag)) + description = search_html[description_start_index:description_end_index] + signatures = None + + else: + signatures = [] + description = str(symbol_heading.find_next_sibling("dd")) + description_pos = search_html.find(description) + # Get text of up to 3 signatures, remove unwanted symbols + for element in [symbol_heading] + symbol_heading.find_next_siblings("dt", limit=2): + signature = UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element.text) + if signature and search_html.find(str(element)) < description_pos: + signatures.append(signature) + + return signatures, description.replace('¶', '') + + @async_cache(arg_offset=1) + async def get_symbol_embed(self, symbol: str) -> Optional[discord.Embed]: + """ + Attempt to scrape and fetch the data for the given `symbol`, and build an embed from its contents. + + If the symbol is known, an Embed with documentation about it is returned. + """ + scraped_html = await self.get_symbol_html(symbol) + if scraped_html is None: + return None + + signatures = scraped_html[0] + permalink = self.inventories[symbol] + description = markdownify(scraped_html[1]) + + # Truncate the description of the embed to the last occurrence + # of a double newline (interpreted as a paragraph) before index 1000. + if len(description) > 1000: + shortened = description[:1000] + description_cutoff = shortened.rfind('\n\n', 100) + if description_cutoff == -1: + # Search the shortened version for cutoff points in decreasing desirability, + # cutoff at 1000 if none are found. + for string in (". ", ", ", ",", " "): + description_cutoff = shortened.rfind(string) + if description_cutoff != -1: + break + else: + description_cutoff = 1000 + description = description[:description_cutoff] + + # If there is an incomplete code block, cut it out + if description.count("```") % 2: + codeblock_start = description.rfind('```py') + description = description[:codeblock_start].rstrip() + description += f"... [read more]({permalink})" + + description = WHITESPACE_AFTER_NEWLINES_RE.sub('', description) + if signatures is None: + # If symbol is a module, don't show signature. + embed_description = description + + elif not signatures: + # It's some "meta-page", for example: + # https://docs.djangoproject.com/en/dev/ref/views/#module-django.views + embed_description = "This appears to be a generic page not tied to a specific symbol." + + else: + embed_description = "".join(f"```py\n{textwrap.shorten(signature, 500)}```" for signature in signatures) + embed_description += f"\n{description}" + + embed = discord.Embed( + title=f'`{symbol}`', + url=permalink, + description=embed_description + ) + # Show all symbols with the same name that were renamed in the footer. + embed.set_footer( + text=", ".join(renamed for renamed in self.renamed_symbols - {symbol} if renamed.endswith(f".{symbol}")) + ) + return embed + + @commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True) + async def docs_group(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None: + """Lookup documentation for Python symbols.""" + await ctx.invoke(self.get_command, symbol) + + @docs_group.command(name='get', aliases=('g',)) + async def get_command(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None: + """ + Return a documentation embed for a given symbol. + + If no symbol is given, return a list of all available inventories. + + Examples: + !docs + !docs aiohttp + !docs aiohttp.ClientSession + !docs get aiohttp.ClientSession + """ + if symbol is None: + inventory_embed = discord.Embed( + title=f"All inventories (`{len(self.base_urls)}` total)", + colour=discord.Colour.blue() + ) + + lines = sorted(f"• [`{name}`]({url})" for name, url in self.base_urls.items()) + if self.base_urls: + await LinePaginator.paginate(lines, ctx, inventory_embed, max_size=400, empty=False) + + else: + inventory_embed.description = "Hmmm, seems like there's nothing here yet." + await ctx.send(embed=inventory_embed) + + else: + # Fetching documentation for a symbol (at least for the first time, since + # caching is used) takes quite some time, so let's send typing to indicate + # that we got the command, but are still working on it. + async with ctx.typing(): + doc_embed = await self.get_symbol_embed(symbol) + + if doc_embed is None: + error_embed = discord.Embed( + description=f"Sorry, I could not find any documentation for `{symbol}`.", + colour=discord.Colour.red() + ) + error_message = await ctx.send(embed=error_embed) + with suppress(NotFound): + await error_message.delete(delay=NOT_FOUND_DELETE_DELAY) + await ctx.message.delete(delay=NOT_FOUND_DELETE_DELAY) + else: + await ctx.send(embed=doc_embed) + + @docs_group.command(name='set', aliases=('s',)) + @with_role(*MODERATION_ROLES) + async def set_command( + self, ctx: commands.Context, package_name: ValidPythonIdentifier, + base_url: ValidURL, inventory_url: InventoryURL + ) -> None: + """ + Adds a new documentation metadata object to the site's database. + + The database will update the object, should an existing item with the specified `package_name` already exist. + + Example: + !docs set \ + python \ + https://docs.python.org/3/ \ + https://docs.python.org/3/objects.inv + """ + body = { + 'package': package_name, + 'base_url': base_url, + 'inventory_url': inventory_url + } + await self.bot.api_client.post('bot/documentation-links', json=body) + + log.info( + f"User @{ctx.author} ({ctx.author.id}) added a new documentation package:\n" + f"Package name: {package_name}\n" + f"Base url: {base_url}\n" + f"Inventory URL: {inventory_url}" + ) + + # Rebuilding the inventory can take some time, so lets send out a + # typing event to show that the Bot is still working. + async with ctx.typing(): + await self.refresh_inventory() + await ctx.send(f"Added package `{package_name}` to database and refreshed inventory.") + + @docs_group.command(name='delete', aliases=('remove', 'rm', 'd')) + @with_role(*MODERATION_ROLES) + async def delete_command(self, ctx: commands.Context, package_name: ValidPythonIdentifier) -> None: + """ + Removes the specified package from the database. + + Examples: + !docs delete aiohttp + """ + await self.bot.api_client.delete(f'bot/documentation-links/{package_name}') + + async with ctx.typing(): + # Rebuild the inventory to ensure that everything + # that was from this package is properly deleted. + await self.refresh_inventory() + await ctx.send(f"Successfully deleted `{package_name}` and refreshed inventory.") + + @docs_group.command(name="refresh", aliases=("rfsh", "r")) + @with_role(*MODERATION_ROLES) + async def refresh_command(self, ctx: commands.Context) -> None: + """Refresh inventories and send differences to channel.""" + old_inventories = set(self.base_urls) + with ctx.typing(): + await self.refresh_inventory() + # Get differences of added and removed inventories + added = ', '.join(inv for inv in self.base_urls if inv not in old_inventories) + if added: + added = f"+ {added}" + + removed = ', '.join(inv for inv in old_inventories if inv not in self.base_urls) + if removed: + removed = f"- {removed}" + + embed = discord.Embed( + title="Inventories refreshed", + description=f"```diff\n{added}\n{removed}```" if added or removed else "" + ) + await ctx.send(embed=embed) + + async def _fetch_inventory(self, inventory_url: str) -> Optional[dict]: + """Get and return inventory from `inventory_url`. If fetching fails, return None.""" + fetch_func = functools.partial(intersphinx.fetch_inventory, SPHINX_MOCK_APP, '', inventory_url) + for retry in range(1, FAILED_REQUEST_RETRY_AMOUNT+1): + try: + package = await self.bot.loop.run_in_executor(None, fetch_func) + except ConnectTimeout: + log.error( + f"Fetching of inventory {inventory_url} timed out," + f" trying again. ({retry}/{FAILED_REQUEST_RETRY_AMOUNT})" + ) + except ProtocolError: + log.error( + f"Connection lost while fetching inventory {inventory_url}," + f" trying again. ({retry}/{FAILED_REQUEST_RETRY_AMOUNT})" + ) + except HTTPError as e: + log.error(f"Fetching of inventory {inventory_url} failed with status code {e.response.status_code}.") + return None + except ConnectionError: + log.error(f"Couldn't establish connection to inventory {inventory_url}.") + return None + else: + return package + log.error(f"Fetching of inventory {inventory_url} failed.") + return None + + @staticmethod + def _match_end_tag(tag: Tag) -> bool: + """Matches `tag` if its class value is in `SEARCH_END_TAG_ATTRS` or the tag is table.""" + for attr in SEARCH_END_TAG_ATTRS: + if attr in tag.get("class", ()): + return True + + return tag.name == "table" + + +def setup(bot: Bot) -> None: + """Load the Doc cog.""" + bot.add_cog(Doc(bot)) diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py new file mode 100644 index 000000000..3d1d6fd10 --- /dev/null +++ b/bot/exts/info/help.py @@ -0,0 +1,375 @@ +import itertools +import logging +from asyncio import TimeoutError +from collections import namedtuple +from contextlib import suppress +from typing import List, Union + +from discord import Colour, Embed, Member, Message, NotFound, Reaction, User +from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand +from fuzzywuzzy import fuzz, process +from fuzzywuzzy.utils import full_process + +from bot import constants +from bot.constants import Channels, Emojis, STAFF_ROLES +from bot.decorators import redirect_output +from bot.pagination import LinePaginator + +log = logging.getLogger(__name__) + +COMMANDS_PER_PAGE = 8 +DELETE_EMOJI = Emojis.trashcan +PREFIX = constants.Bot.prefix + +Category = namedtuple("Category", ["name", "description", "cogs"]) + + +async def help_cleanup(bot: Bot, author: Member, message: Message) -> None: + """ + Runs the cleanup for the help command. + + Adds the :trashcan: reaction that, when clicked, will delete the help message. + After a 300 second timeout, the reaction will be removed. + """ + def check(reaction: Reaction, user: User) -> bool: + """Checks the reaction is :trashcan:, the author is original author and messages are the same.""" + return str(reaction) == DELETE_EMOJI and user.id == author.id and reaction.message.id == message.id + + await message.add_reaction(DELETE_EMOJI) + + with suppress(NotFound): + try: + await bot.wait_for("reaction_add", check=check, timeout=300) + await message.delete() + except TimeoutError: + await message.remove_reaction(DELETE_EMOJI, bot.user) + + +class HelpQueryNotFound(ValueError): + """ + Raised when a HelpSession Query doesn't match a command or cog. + + Contains the custom attribute of ``possible_matches``. + + Instances of this object contain a dictionary of any command(s) that were close to matching the + query, where keys are the possible matched command names and values are the likeness match scores. + """ + + def __init__(self, arg: str, possible_matches: dict = None): + super().__init__(arg) + self.possible_matches = possible_matches + + +class CustomHelpCommand(HelpCommand): + """ + An interactive instance for the bot help command. + + Cogs can be grouped into custom categories. All cogs with the same category will be displayed + under a single category name in the help output. Custom categories are defined inside the cogs + as a class attribute named `category`. A description can also be specified with the attribute + `category_description`. If a description is not found in at least one cog, the default will be + the regular description (class docstring) of the first cog found in the category. + """ + + def __init__(self): + super().__init__(command_attrs={"help": "Shows help for bot commands"}) + + @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES) + async def command_callback(self, ctx: Context, *, command: str = None) -> None: + """Attempts to match the provided query with a valid command or cog.""" + # the only reason we need to tamper with this is because d.py does not support "categories", + # so we need to deal with them ourselves. + + bot = ctx.bot + + if command is None: + # quick and easy, send bot help if command is none + mapping = self.get_bot_mapping() + await self.send_bot_help(mapping) + return + + cog_matches = [] + description = None + for cog in bot.cogs.values(): + if hasattr(cog, "category") and cog.category == command: + cog_matches.append(cog) + if hasattr(cog, "category_description"): + description = cog.category_description + + if cog_matches: + category = Category(name=command, description=description, cogs=cog_matches) + await self.send_category_help(category) + return + + # it's either a cog, group, command or subcommand; let the parent class deal with it + await super().command_callback(ctx, command=command) + + async def get_all_help_choices(self) -> set: + """ + Get all the possible options for getting help in the bot. + + This will only display commands the author has permission to run. + + These include: + - Category names + - Cog names + - Group command names (and aliases) + - Command names (and aliases) + - Subcommand names (with parent group and aliases for subcommand, but not including aliases for group) + + Options and choices are case sensitive. + """ + # first get all commands including subcommands and full command name aliases + choices = set() + for command in await self.filter_commands(self.context.bot.walk_commands()): + # the the command or group name + choices.add(str(command)) + + if isinstance(command, Command): + # all aliases if it's just a command + choices.update(command.aliases) + else: + # otherwise we need to add the parent name in + choices.update(f"{command.full_parent_name} {alias}" for alias in command.aliases) + + # all cog names + choices.update(self.context.bot.cogs) + + # all category names + choices.update(cog.category for cog in self.context.bot.cogs.values() if hasattr(cog, "category")) + return choices + + async def command_not_found(self, string: str) -> "HelpQueryNotFound": + """ + Handles when a query does not match a valid command, group, cog or category. + + Will return an instance of the `HelpQueryNotFound` exception with the error message and possible matches. + """ + choices = await self.get_all_help_choices() + + # Run fuzzywuzzy's processor beforehand, and avoid matching if processed string is empty + # This avoids fuzzywuzzy from raising a warning on inputs with only non-alphanumeric characters + if (processed := full_process(string)): + result = process.extractBests(processed, choices, scorer=fuzz.ratio, score_cutoff=60, processor=None) + else: + result = [] + + return HelpQueryNotFound(f'Query "{string}" not found.', dict(result)) + + async def subcommand_not_found(self, command: Command, string: str) -> "HelpQueryNotFound": + """ + Redirects the error to `command_not_found`. + + `command_not_found` deals with searching and getting best choices for both commands and subcommands. + """ + return await self.command_not_found(f"{command.qualified_name} {string}") + + async def send_error_message(self, error: HelpQueryNotFound) -> None: + """Send the error message to the channel.""" + embed = Embed(colour=Colour.red(), title=str(error)) + + if getattr(error, "possible_matches", None): + matches = "\n".join(f"`{match}`" for match in error.possible_matches) + embed.description = f"**Did you mean:**\n{matches}" + + await self.context.send(embed=embed) + + async def command_formatting(self, command: Command) -> Embed: + """ + Takes a command and turns it into an embed. + + It will add an author, command signature + help, aliases and a note if the user can't run the command. + """ + embed = Embed() + embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) + + parent = command.full_parent_name + + name = str(command) if not parent else f"{parent} {command.name}" + command_details = f"**```{PREFIX}{name} {command.signature}```**\n" + + # show command aliases + aliases = ", ".join(f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in command.aliases) + if aliases: + command_details += f"**Can also use:** {aliases}\n\n" + + # check if the user is allowed to run this command + if not await command.can_run(self.context): + command_details += "***You cannot run this command.***\n\n" + + command_details += f"*{command.help or 'No details provided.'}*\n" + embed.description = command_details + + return embed + + async def send_command_help(self, command: Command) -> None: + """Send help for a single command.""" + embed = await self.command_formatting(command) + message = await self.context.send(embed=embed) + await help_cleanup(self.context.bot, self.context.author, message) + + @staticmethod + def get_commands_brief_details(commands_: List[Command], return_as_list: bool = False) -> Union[List[str], str]: + """ + Formats the prefix, command name and signature, and short doc for an iterable of commands. + + return_as_list is helpful for passing these command details into the paginator as a list of command details. + """ + details = [] + for command in commands_: + signature = f" {command.signature}" if command.signature else "" + details.append( + f"\n**`{PREFIX}{command.qualified_name}{signature}`**\n*{command.short_doc or 'No details provided'}*" + ) + if return_as_list: + return details + else: + return "".join(details) + + async def send_group_help(self, group: Group) -> None: + """Sends help for a group command.""" + subcommands = group.commands + + if len(subcommands) == 0: + # no subcommands, just treat it like a regular command + await self.send_command_help(group) + return + + # remove commands that the user can't run and are hidden, and sort by name + commands_ = await self.filter_commands(subcommands, sort=True) + + embed = await self.command_formatting(group) + + command_details = self.get_commands_brief_details(commands_) + if command_details: + embed.description += f"\n**Subcommands:**\n{command_details}" + + message = await self.context.send(embed=embed) + await help_cleanup(self.context.bot, self.context.author, message) + + async def send_cog_help(self, cog: Cog) -> None: + """Send help for a cog.""" + # sort commands by name, and remove any the user cant run or are hidden. + commands_ = await self.filter_commands(cog.get_commands(), sort=True) + + embed = Embed() + embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) + embed.description = f"**{cog.qualified_name}**\n*{cog.description}*" + + command_details = self.get_commands_brief_details(commands_) + if command_details: + embed.description += f"\n\n**Commands:**\n{command_details}" + + message = await self.context.send(embed=embed) + await help_cleanup(self.context.bot, self.context.author, message) + + @staticmethod + def _category_key(command: Command) -> str: + """ + Returns a cog name of a given command for use as a key for `sorted` and `groupby`. + + A zero width space is used as a prefix for results with no cogs to force them last in ordering. + """ + if command.cog: + with suppress(AttributeError): + if command.cog.category: + return f"**{command.cog.category}**" + return f"**{command.cog_name}**" + else: + return "**\u200bNo Category:**" + + async def send_category_help(self, category: Category) -> None: + """ + Sends help for a bot category. + + This sends a brief help for all commands in all cogs registered to the category. + """ + embed = Embed() + embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) + + all_commands = [] + for cog in category.cogs: + all_commands.extend(cog.get_commands()) + + filtered_commands = await self.filter_commands(all_commands, sort=True) + + command_detail_lines = self.get_commands_brief_details(filtered_commands, return_as_list=True) + description = f"**{category.name}**\n*{category.description}*" + + if command_detail_lines: + description += "\n\n**Commands:**" + + await LinePaginator.paginate( + command_detail_lines, + self.context, + embed, + prefix=description, + max_lines=COMMANDS_PER_PAGE, + max_size=2000, + ) + + async def send_bot_help(self, mapping: dict) -> None: + """Sends help for all bot commands and cogs.""" + bot = self.context.bot + + embed = Embed() + embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) + + filter_commands = await self.filter_commands(bot.commands, sort=True, key=self._category_key) + + cog_or_category_pages = [] + + for cog_or_category, _commands in itertools.groupby(filter_commands, key=self._category_key): + sorted_commands = sorted(_commands, key=lambda c: c.name) + + if len(sorted_commands) == 0: + continue + + command_detail_lines = self.get_commands_brief_details(sorted_commands, return_as_list=True) + + # Split cogs or categories which have too many commands to fit in one page. + # The length of commands is included for later use when aggregating into pages for the paginator. + for index in range(0, len(sorted_commands), COMMANDS_PER_PAGE): + truncated_lines = command_detail_lines[index:index + COMMANDS_PER_PAGE] + joined_lines = "".join(truncated_lines) + cog_or_category_pages.append((f"**{cog_or_category}**{joined_lines}", len(truncated_lines))) + + pages = [] + counter = 0 + page = "" + for page_details, length in cog_or_category_pages: + counter += length + if counter > COMMANDS_PER_PAGE: + # force a new page on paginator even if it falls short of the max pages + # since we still want to group categories/cogs. + counter = length + pages.append(page) + page = f"{page_details}\n\n" + else: + page += f"{page_details}\n\n" + + if page: + # add any remaining command help that didn't get added in the last iteration above. + pages.append(page) + + await LinePaginator.paginate(pages, self.context, embed=embed, max_lines=1, max_size=2000) + + +class Help(Cog): + """Custom Embed Pagination Help feature.""" + + def __init__(self, bot: Bot) -> None: + self.bot = bot + self.old_help_command = bot.help_command + bot.help_command = CustomHelpCommand() + bot.help_command.cog = self + + def cog_unload(self) -> None: + """Reset the help command when the cog is unloaded.""" + self.bot.help_command = self.old_help_command + + +def setup(bot: Bot) -> None: + """Load the Help cog.""" + bot.add_cog(Help(bot)) + log.info("Cog loaded: Help") diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py new file mode 100644 index 000000000..8982196d1 --- /dev/null +++ b/bot/exts/info/information.py @@ -0,0 +1,422 @@ +import colorsys +import logging +import pprint +import textwrap +from collections import Counter, defaultdict +from string import Template +from typing import Any, Mapping, Optional, Union + +from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, utils +from discord.abc import GuildChannel +from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group +from discord.utils import escape_markdown + +from bot import constants +from bot.bot import Bot +from bot.decorators import in_whitelist, with_role +from bot.pagination import LinePaginator +from bot.utils.checks import InWhitelistCheckFailure, cooldown_with_role_bypass, with_role_check +from bot.utils.time import time_since + +log = logging.getLogger(__name__) + + +class Information(Cog): + """A cog with commands for generating embeds with server info, such as server stats and user info.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @staticmethod + def role_can_read(channel: GuildChannel, role: Role) -> bool: + """Return True if `role` can read messages in `channel`.""" + overwrites = channel.overwrites_for(role) + return overwrites.read_messages is True + + def get_staff_channel_count(self, guild: Guild) -> int: + """ + Get the number of channels that are staff-only. + + We need to know two things about a channel: + - Does the @everyone role have explicit read deny permissions? + - Do staff roles have explicit read allow permissions? + + If the answer to both of these questions is yes, it's a staff channel. + """ + channel_ids = set() + for channel in guild.channels: + if channel.type is ChannelType.category: + continue + + everyone_can_read = self.role_can_read(channel, guild.default_role) + + for role in constants.STAFF_ROLES: + role_can_read = self.role_can_read(channel, guild.get_role(role)) + if role_can_read and not everyone_can_read: + channel_ids.add(channel.id) + break + + return len(channel_ids) + + @staticmethod + def get_channel_type_counts(guild: Guild) -> str: + """Return the total amounts of the various types of channels in `guild`.""" + channel_counter = Counter(c.type for c in guild.channels) + channel_type_list = [] + for channel, count in channel_counter.items(): + channel_type = str(channel).title() + channel_type_list.append(f"{channel_type} channels: {count}") + + channel_type_list = sorted(channel_type_list) + return "\n".join(channel_type_list) + + @with_role(*constants.MODERATION_ROLES) + @command(name="roles") + async def roles_info(self, ctx: Context) -> None: + """Returns a list of all roles and their corresponding IDs.""" + # Sort the roles alphabetically and remove the @everyone role + roles = sorted(ctx.guild.roles[1:], key=lambda role: role.name) + + # Build a list + role_list = [] + for role in roles: + role_list.append(f"`{role.id}` - {role.mention}") + + # Build an embed + embed = Embed( + title=f"Role information (Total {len(roles)} role{'s' * (len(role_list) > 1)})", + colour=Colour.blurple() + ) + + await LinePaginator.paginate(role_list, ctx, embed, empty=False) + + @with_role(*constants.MODERATION_ROLES) + @command(name="role") + async def role_info(self, ctx: Context, *roles: Union[Role, str]) -> None: + """ + Return information on a role or list of roles. + + To specify multiple roles just add to the arguments, delimit roles with spaces in them using quotation marks. + """ + parsed_roles = [] + failed_roles = [] + + for role_name in roles: + if isinstance(role_name, Role): + # Role conversion has already succeeded + parsed_roles.append(role_name) + continue + + role = utils.find(lambda r: r.name.lower() == role_name.lower(), ctx.guild.roles) + + if not role: + failed_roles.append(role_name) + continue + + parsed_roles.append(role) + + if failed_roles: + await ctx.send(f":x: Could not retrieve the following roles: {', '.join(failed_roles)}") + + for role in parsed_roles: + h, s, v = colorsys.rgb_to_hsv(*role.colour.to_rgb()) + + embed = Embed( + title=f"{role.name} info", + colour=role.colour, + ) + embed.add_field(name="ID", value=role.id, inline=True) + embed.add_field(name="Colour (RGB)", value=f"#{role.colour.value:0>6x}", inline=True) + embed.add_field(name="Colour (HSV)", value=f"{h:.2f} {s:.2f} {v}", inline=True) + embed.add_field(name="Member count", value=len(role.members), inline=True) + embed.add_field(name="Position", value=role.position) + embed.add_field(name="Permission code", value=role.permissions.value, inline=True) + + await ctx.send(embed=embed) + + @command(name="server", aliases=["server_info", "guild", "guild_info"]) + async def server_info(self, ctx: Context) -> None: + """Returns an embed full of server information.""" + created = time_since(ctx.guild.created_at, precision="days") + features = ", ".join(ctx.guild.features) + region = ctx.guild.region + + roles = len(ctx.guild.roles) + member_count = ctx.guild.member_count + channel_counts = self.get_channel_type_counts(ctx.guild) + + # How many of each user status? + statuses = Counter(member.status for member in ctx.guild.members) + embed = Embed(colour=Colour.blurple()) + + # How many staff members and staff channels do we have? + staff_member_count = len(ctx.guild.get_role(constants.Roles.helpers).members) + staff_channel_count = self.get_staff_channel_count(ctx.guild) + + # Because channel_counts lacks leading whitespace, it breaks the dedent if it's inserted directly by the + # f-string. While this is correctly formated by Discord, it makes unit testing difficult. To keep the formatting + # without joining a tuple of strings we can use a Template string to insert the already-formatted channel_counts + # after the dedent is made. + embed.description = Template( + textwrap.dedent(f""" + **Server information** + Created: {created} + Voice region: {region} + Features: {features} + + **Channel counts** + $channel_counts + Staff channels: {staff_channel_count} + + **Member counts** + Members: {member_count:,} + Staff members: {staff_member_count} + Roles: {roles} + + **Member statuses** + {constants.Emojis.status_online} {statuses[Status.online]:,} + {constants.Emojis.status_idle} {statuses[Status.idle]:,} + {constants.Emojis.status_dnd} {statuses[Status.dnd]:,} + {constants.Emojis.status_offline} {statuses[Status.offline]:,} + """) + ).substitute({"channel_counts": channel_counts}) + embed.set_thumbnail(url=ctx.guild.icon_url) + + await ctx.send(embed=embed) + + @command(name="user", aliases=["user_info", "member", "member_info"]) + async def user_info(self, ctx: Context, user: Member = None) -> None: + """Returns info about a user.""" + if user is None: + user = ctx.author + + # Do a role check if this is being executed on someone other than the caller + elif user != ctx.author and not with_role_check(ctx, *constants.MODERATION_ROLES): + await ctx.send("You may not use this command on users other than yourself.") + return + + # Non-staff may only do this in #bot-commands + if not with_role_check(ctx, *constants.STAFF_ROLES): + if not ctx.channel.id == constants.Channels.bot_commands: + raise InWhitelistCheckFailure(constants.Channels.bot_commands) + + embed = await self.create_user_embed(ctx, user) + + await ctx.send(embed=embed) + + async def create_user_embed(self, ctx: Context, user: Member) -> Embed: + """Creates an embed containing information on the `user`.""" + created = time_since(user.created_at, max_units=3) + + # Custom status + custom_status = '' + for activity in user.activities: + # Check activity.state for None value if user has a custom status set + # This guards against a custom status with an emoji but no text, which will cause + # escape_markdown to raise an exception + # This can be reworked after a move to d.py 1.3.0+, which adds a CustomActivity class + if activity.name == 'Custom Status' and activity.state: + state = escape_markdown(activity.state) + custom_status = f'Status: {state}\n' + + name = str(user) + if user.nick: + name = f"{user.nick} ({name})" + + joined = time_since(user.joined_at, max_units=3) + roles = ", ".join(role.mention for role in user.roles[1:]) + + description = [ + textwrap.dedent(f""" + **User Information** + Created: {created} + Profile: {user.mention} + ID: {user.id} + {custom_status} + **Member Information** + Joined: {joined} + Roles: {roles or None} + """).strip() + ] + + # Show more verbose output in moderation channels for infractions and nominations + if ctx.channel.id in constants.MODERATION_CHANNELS: + description.append(await self.expanded_user_infraction_counts(user)) + description.append(await self.user_nomination_counts(user)) + else: + description.append(await self.basic_user_infraction_counts(user)) + + # Let's build the embed now + embed = Embed( + title=name, + description="\n\n".join(description) + ) + + embed.set_thumbnail(url=user.avatar_url_as(static_format="png")) + embed.colour = user.top_role.colour if roles else Colour.blurple() + + return embed + + async def basic_user_infraction_counts(self, member: Member) -> str: + """Gets the total and active infraction counts for the given `member`.""" + infractions = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'hidden': 'False', + 'user__id': str(member.id) + } + ) + + total_infractions = len(infractions) + active_infractions = sum(infraction['active'] for infraction in infractions) + + infraction_output = f"**Infractions**\nTotal: {total_infractions}\nActive: {active_infractions}" + + return infraction_output + + async def expanded_user_infraction_counts(self, member: Member) -> str: + """ + Gets expanded infraction counts for the given `member`. + + The counts will be split by infraction type and the number of active infractions for each type will indicated + in the output as well. + """ + infractions = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'user__id': str(member.id) + } + ) + + infraction_output = ["**Infractions**"] + if not infractions: + infraction_output.append("This user has never received an infraction.") + else: + # Count infractions split by `type` and `active` status for this user + infraction_types = set() + infraction_counter = defaultdict(int) + for infraction in infractions: + infraction_type = infraction["type"] + infraction_active = 'active' if infraction["active"] else 'inactive' + + infraction_types.add(infraction_type) + infraction_counter[f"{infraction_active} {infraction_type}"] += 1 + + # Format the output of the infraction counts + for infraction_type in sorted(infraction_types): + active_count = infraction_counter[f"active {infraction_type}"] + total_count = active_count + infraction_counter[f"inactive {infraction_type}"] + + line = f"{infraction_type.capitalize()}s: {total_count}" + if active_count: + line += f" ({active_count} active)" + + infraction_output.append(line) + + return "\n".join(infraction_output) + + async def user_nomination_counts(self, member: Member) -> str: + """Gets the active and historical nomination counts for the given `member`.""" + nominations = await self.bot.api_client.get( + 'bot/nominations', + params={ + 'user__id': str(member.id) + } + ) + + output = ["**Nominations**"] + + if not nominations: + output.append("This user has never been nominated.") + else: + count = len(nominations) + is_currently_nominated = any(nomination["active"] for nomination in nominations) + nomination_noun = "nomination" if count == 1 else "nominations" + + if is_currently_nominated: + output.append(f"This user is **currently** nominated ({count} {nomination_noun} in total).") + else: + output.append(f"This user has {count} historical {nomination_noun}, but is currently not nominated.") + + return "\n".join(output) + + def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str: + """Format a mapping to be readable to a human.""" + # sorting is technically superfluous but nice if you want to look for a specific field + fields = sorted(mapping.items(), key=lambda item: item[0]) + + if field_width is None: + field_width = len(max(mapping.keys(), key=len)) + + out = '' + + for key, val in fields: + if isinstance(val, dict): + # if we have dicts inside dicts we want to apply the same treatment to the inner dictionaries + inner_width = int(field_width * 1.6) + val = '\n' + self.format_fields(val, field_width=inner_width) + + elif isinstance(val, str): + # split up text since it might be long + text = textwrap.fill(val, width=100, replace_whitespace=False) + + # indent it, I guess you could do this with `wrap` and `join` but this is nicer + val = textwrap.indent(text, ' ' * (field_width + len(': '))) + + # the first line is already indented so we `str.lstrip` it + val = val.lstrip() + + if key == 'color': + # makes the base 10 representation of a hex number readable to humans + val = hex(val) + + out += '{0:>{width}}: {1}\n'.format(key, val, width=field_width) + + # remove trailing whitespace + return out.rstrip() + + @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES) + @group(invoke_without_command=True) + @in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_ROLES) + async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None: + """Shows information about the raw API response.""" + # I *guess* it could be deleted right as the command is invoked but I felt like it wasn't worth handling + # doing this extra request is also much easier than trying to convert everything back into a dictionary again + raw_data = await ctx.bot.http.get_message(message.channel.id, message.id) + + paginator = Paginator() + + def add_content(title: str, content: str) -> None: + paginator.add_line(f'== {title} ==\n') + # replace backticks as it breaks out of code blocks. Spaces seemed to be the most reasonable solution. + # we hope it's not close to 2000 + paginator.add_line(content.replace('```', '`` `')) + paginator.close_page() + + if message.content: + add_content('Raw message', message.content) + + transformer = pprint.pformat if json else self.format_fields + for field_name in ('embeds', 'attachments'): + data = raw_data[field_name] + + if not data: + continue + + total = len(data) + for current, item in enumerate(data, start=1): + title = f'Raw {field_name} ({current}/{total})' + add_content(title, transformer(item)) + + for page in paginator.pages: + await ctx.send(page) + + @raw.command() + async def json(self, ctx: Context, message: Message) -> None: + """Shows information about the raw API response in a copy-pasteable Python format.""" + await ctx.invoke(self.raw, message=message, json=True) + + +def setup(bot: Bot) -> None: + """Load the Information cog.""" + bot.add_cog(Information(bot)) diff --git a/bot/exts/info/python_news.py b/bot/exts/info/python_news.py new file mode 100644 index 000000000..0ab5738a4 --- /dev/null +++ b/bot/exts/info/python_news.py @@ -0,0 +1,232 @@ +import logging +import typing as t +from datetime import date, datetime + +import discord +import feedparser +from bs4 import BeautifulSoup +from discord.ext.commands import Cog +from discord.ext.tasks import loop + +from bot import constants +from bot.bot import Bot +from bot.utils.webhooks import send_webhook + +PEPS_RSS_URL = "https://www.python.org/dev/peps/peps.rss/" + +RECENT_THREADS_TEMPLATE = "https://mail.python.org/archives/list/{name}@python.org/recent-threads" +THREAD_TEMPLATE_URL = "https://mail.python.org/archives/api/list/{name}@python.org/thread/{id}/" +MAILMAN_PROFILE_URL = "https://mail.python.org/archives/users/{id}/" +THREAD_URL = "https://mail.python.org/archives/list/{list}@python.org/thread/{id}/" + +AVATAR_URL = "https://www.python.org/static/opengraph-icon-200x200.png" + +log = logging.getLogger(__name__) + + +class PythonNews(Cog): + """Post new PEPs and Python News to `#python-news`.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.webhook_names = {} + self.webhook: t.Optional[discord.Webhook] = None + + self.bot.loop.create_task(self.get_webhook_names()) + self.bot.loop.create_task(self.get_webhook_and_channel()) + + async def start_tasks(self) -> None: + """Start the tasks for fetching new PEPs and mailing list messages.""" + self.fetch_new_media.start() + + @loop(minutes=20) + async def fetch_new_media(self) -> None: + """Fetch new mailing list messages and then new PEPs.""" + await self.post_maillist_news() + await self.post_pep_news() + + async def sync_maillists(self) -> None: + """Sync currently in-use maillists with API.""" + # Wait until guild is available to avoid running before everything is ready + await self.bot.wait_until_guild_available() + + response = await self.bot.api_client.get("bot/bot-settings/news") + for mail in constants.PythonNews.mail_lists: + if mail not in response["data"]: + response["data"][mail] = [] + + # Because we are handling PEPs differently, we don't include it to mail lists + if "pep" not in response["data"]: + response["data"]["pep"] = [] + + await self.bot.api_client.put("bot/bot-settings/news", json=response) + + async def get_webhook_names(self) -> None: + """Get webhook author names from maillist API.""" + await self.bot.wait_until_guild_available() + + async with self.bot.http_session.get("https://mail.python.org/archives/api/lists") as resp: + lists = await resp.json() + + for mail in lists: + if mail["name"].split("@")[0] in constants.PythonNews.mail_lists: + self.webhook_names[mail["name"].split("@")[0]] = mail["display_name"] + + async def post_pep_news(self) -> None: + """Fetch new PEPs and when they don't have announcement in #python-news, create it.""" + # Wait until everything is ready and http_session available + await self.bot.wait_until_guild_available() + await self.sync_maillists() + + async with self.bot.http_session.get(PEPS_RSS_URL) as resp: + data = feedparser.parse(await resp.text("utf-8")) + + news_listing = await self.bot.api_client.get("bot/bot-settings/news") + payload = news_listing.copy() + pep_numbers = news_listing["data"]["pep"] + + # Reverse entries to send oldest first + data["entries"].reverse() + for new in data["entries"]: + try: + new_datetime = datetime.strptime(new["published"], "%a, %d %b %Y %X %Z") + except ValueError: + log.warning(f"Wrong datetime format passed in PEP new: {new['published']}") + continue + pep_nr = new["title"].split(":")[0].split()[1] + if ( + pep_nr in pep_numbers + or new_datetime.date() < date.today() + ): + continue + + # Build an embed and send a webhook + embed = discord.Embed( + title=new["title"], + description=new["summary"], + timestamp=new_datetime, + url=new["link"], + colour=constants.Colours.soft_green + ) + embed.set_footer(text=data["feed"]["title"], icon_url=AVATAR_URL) + msg = await send_webhook( + webhook=self.webhook, + username=data["feed"]["title"], + embed=embed, + avatar_url=AVATAR_URL, + wait=True, + ) + payload["data"]["pep"].append(pep_nr) + + # Increase overall PEP new stat + self.bot.stats.incr("python_news.posted.pep") + + if msg.channel.is_news(): + log.trace("Publishing PEP annnouncement because it was in a news channel") + await msg.publish() + + # Apply new sent news to DB to avoid duplicate sending + await self.bot.api_client.put("bot/bot-settings/news", json=payload) + + async def post_maillist_news(self) -> None: + """Send new maillist threads to #python-news that is listed in configuration.""" + await self.bot.wait_until_guild_available() + await self.sync_maillists() + existing_news = await self.bot.api_client.get("bot/bot-settings/news") + payload = existing_news.copy() + + for maillist in constants.PythonNews.mail_lists: + async with self.bot.http_session.get(RECENT_THREADS_TEMPLATE.format(name=maillist)) as resp: + recents = BeautifulSoup(await resp.text(), features="lxml") + + # When a

element is present in the response then the mailing list + # has not had any activity during the current month, so therefore it + # can be ignored. + if recents.p: + continue + + for thread in recents.html.body.div.find_all("a", href=True): + # We want only these threads that have identifiers + if "latest" in thread["href"]: + continue + + thread_information, email_information = await self.get_thread_and_first_mail( + maillist, thread["href"].split("/")[-2] + ) + + try: + new_date = datetime.strptime(email_information["date"], "%Y-%m-%dT%X%z") + except ValueError: + log.warning(f"Invalid datetime from Thread email: {email_information['date']}") + continue + + if ( + thread_information["thread_id"] in existing_news["data"][maillist] + or 'Re: ' in thread_information["subject"] + or new_date.date() < date.today() + ): + continue + + content = email_information["content"] + link = THREAD_URL.format(id=thread["href"].split("/")[-2], list=maillist) + + # Build an embed and send a message to the webhook + embed = discord.Embed( + title=thread_information["subject"], + description=content[:500] + f"... [continue reading]({link})" if len(content) > 500 else content, + timestamp=new_date, + url=link, + colour=constants.Colours.soft_green + ) + embed.set_author( + name=f"{email_information['sender_name']} ({email_information['sender']['address']})", + url=MAILMAN_PROFILE_URL.format(id=email_information["sender"]["mailman_id"]), + ) + embed.set_footer( + text=f"Posted to {self.webhook_names[maillist]}", + icon_url=AVATAR_URL, + ) + msg = await send_webhook( + webhook=self.webhook, + username=self.webhook_names[maillist], + embed=embed, + avatar_url=AVATAR_URL, + wait=True, + ) + payload["data"][maillist].append(thread_information["thread_id"]) + + # Increase this specific maillist counter in stats + self.bot.stats.incr(f"python_news.posted.{maillist.replace('-', '_')}") + + if msg.channel.is_news(): + log.trace("Publishing mailing list message because it was in a news channel") + await msg.publish() + + await self.bot.api_client.put("bot/bot-settings/news", json=payload) + + async def get_thread_and_first_mail(self, maillist: str, thread_identifier: str) -> t.Tuple[t.Any, t.Any]: + """Get mail thread and first mail from mail.python.org based on `maillist` and `thread_identifier`.""" + async with self.bot.http_session.get( + THREAD_TEMPLATE_URL.format(name=maillist, id=thread_identifier) + ) as resp: + thread_information = await resp.json() + + async with self.bot.http_session.get(thread_information["starting_email"]) as resp: + email_information = await resp.json() + return thread_information, email_information + + async def get_webhook_and_channel(self) -> None: + """Storage #python-news channel Webhook and `TextChannel` to `News.webhook` and `channel`.""" + await self.bot.wait_until_guild_available() + self.webhook = await self.bot.fetch_webhook(constants.PythonNews.webhook) + + await self.start_tasks() + + def cog_unload(self) -> None: + """Stop news posting tasks on cog unload.""" + self.fetch_new_media.cancel() + + +def setup(bot: Bot) -> None: + """Add `News` cog.""" + bot.add_cog(PythonNews(bot)) diff --git a/bot/exts/info/reddit.py b/bot/exts/info/reddit.py new file mode 100644 index 000000000..d853ab2ea --- /dev/null +++ b/bot/exts/info/reddit.py @@ -0,0 +1,304 @@ +import asyncio +import logging +import random +import textwrap +from collections import namedtuple +from datetime import datetime, timedelta +from typing import List + +from aiohttp import BasicAuth, ClientError +from discord import Colour, Embed, TextChannel +from discord.ext.commands import Cog, Context, group +from discord.ext.tasks import loop + +from bot.bot import Bot +from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES, Webhooks +from bot.converters import Subreddit +from bot.decorators import with_role +from bot.pagination import LinePaginator +from bot.utils.messages import sub_clyde + +log = logging.getLogger(__name__) + +AccessToken = namedtuple("AccessToken", ["token", "expires_at"]) + + +class Reddit(Cog): + """Track subreddit posts and show detailed statistics about them.""" + + HEADERS = {"User-Agent": "python3:python-discord/bot:1.0.0 (by /u/PythonDiscord)"} + URL = "https://www.reddit.com" + OAUTH_URL = "https://oauth.reddit.com" + MAX_RETRIES = 3 + + def __init__(self, bot: Bot): + self.bot = bot + + self.webhook = None + self.access_token = None + self.client_auth = BasicAuth(RedditConfig.client_id, RedditConfig.secret) + + bot.loop.create_task(self.init_reddit_ready()) + self.auto_poster_loop.start() + + def cog_unload(self) -> None: + """Stop the loop task and revoke the access token when the cog is unloaded.""" + self.auto_poster_loop.cancel() + if self.access_token and self.access_token.expires_at > datetime.utcnow(): + asyncio.create_task(self.revoke_access_token()) + + async def init_reddit_ready(self) -> None: + """Sets the reddit webhook when the cog is loaded.""" + await self.bot.wait_until_guild_available() + if not self.webhook: + self.webhook = await self.bot.fetch_webhook(Webhooks.reddit) + + @property + def channel(self) -> TextChannel: + """Get the #reddit channel object from the bot's cache.""" + return self.bot.get_channel(Channels.reddit) + + async def get_access_token(self) -> None: + """ + Get a Reddit API OAuth2 access token and assign it to self.access_token. + + A token is valid for 1 hour. There will be MAX_RETRIES to get a token, after which the cog + will be unloaded and a ClientError raised if retrieval was still unsuccessful. + """ + for i in range(1, self.MAX_RETRIES + 1): + response = await self.bot.http_session.post( + url=f"{self.URL}/api/v1/access_token", + headers=self.HEADERS, + auth=self.client_auth, + data={ + "grant_type": "client_credentials", + "duration": "temporary" + } + ) + + if response.status == 200 and response.content_type == "application/json": + content = await response.json() + expiration = int(content["expires_in"]) - 60 # Subtract 1 minute for leeway. + self.access_token = AccessToken( + token=content["access_token"], + expires_at=datetime.utcnow() + timedelta(seconds=expiration) + ) + + log.debug(f"New token acquired; expires on UTC {self.access_token.expires_at}") + return + else: + log.debug( + f"Failed to get an access token: " + f"status {response.status} & content type {response.content_type}; " + f"retrying ({i}/{self.MAX_RETRIES})" + ) + + await asyncio.sleep(3) + + self.bot.remove_cog(self.qualified_name) + raise ClientError("Authentication with the Reddit API failed. Unloading the cog.") + + async def revoke_access_token(self) -> None: + """ + Revoke the OAuth2 access token for the Reddit API. + + For security reasons, it's good practice to revoke the token when it's no longer being used. + """ + response = await self.bot.http_session.post( + url=f"{self.URL}/api/v1/revoke_token", + headers=self.HEADERS, + auth=self.client_auth, + data={ + "token": self.access_token.token, + "token_type_hint": "access_token" + } + ) + + if response.status == 204 and response.content_type == "application/json": + self.access_token = None + else: + log.warning(f"Unable to revoke access token: status {response.status}.") + + async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> List[dict]: + """A helper method to fetch a certain amount of Reddit posts at a given route.""" + # Reddit's JSON responses only provide 25 posts at most. + if not 25 >= amount > 0: + raise ValueError("Invalid amount of subreddit posts requested.") + + # Renew the token if necessary. + if not self.access_token or self.access_token.expires_at < datetime.utcnow(): + await self.get_access_token() + + url = f"{self.OAUTH_URL}/{route}" + for _ in range(self.MAX_RETRIES): + response = await self.bot.http_session.get( + url=url, + headers={**self.HEADERS, "Authorization": f"bearer {self.access_token.token}"}, + params=params + ) + if response.status == 200 and response.content_type == 'application/json': + # Got appropriate response - process and return. + content = await response.json() + posts = content["data"]["children"] + return posts[:amount] + + await asyncio.sleep(3) + + log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}") + return list() # Failed to get appropriate response within allowed number of retries. + + async def get_top_posts(self, subreddit: Subreddit, time: str = "all", amount: int = 5) -> Embed: + """ + Get the top amount of posts for a given subreddit within a specified timeframe. + + A time of "all" will get posts from all time, "day" will get top daily posts and "week" will get the top + weekly posts. + + The amount should be between 0 and 25 as Reddit's JSON requests only provide 25 posts at most. + """ + embed = Embed(description="") + + posts = await self.fetch_posts( + route=f"{subreddit}/top", + amount=amount, + params={"t": time} + ) + + if not posts: + embed.title = random.choice(ERROR_REPLIES) + embed.colour = Colour.red() + embed.description = ( + "Sorry! We couldn't find any posts from that subreddit. " + "If this problem persists, please let us know." + ) + + return embed + + for post in posts: + data = post["data"] + + text = data["selftext"] + if text: + text = textwrap.shorten(text, width=128, placeholder="...") + text += "\n" # Add newline to separate embed info + + ups = data["ups"] + comments = data["num_comments"] + author = data["author"] + + title = textwrap.shorten(data["title"], width=64, placeholder="...") + link = self.URL + data["permalink"] + + embed.description += ( + f"**[{title}]({link})**\n" + f"{text}" + f"{Emojis.upvotes} {ups} {Emojis.comments} {comments} {Emojis.user} {author}\n\n" + ) + + embed.colour = Colour.blurple() + return embed + + @loop() + async def auto_poster_loop(self) -> None: + """Post the top 5 posts daily, and the top 5 posts weekly.""" + # once we upgrade to d.py 1.3 this can be removed and the loop can use the `time=datetime.time.min` parameter + now = datetime.utcnow() + tomorrow = now + timedelta(days=1) + midnight_tomorrow = tomorrow.replace(hour=0, minute=0, second=0) + seconds_until = (midnight_tomorrow - now).total_seconds() + + await asyncio.sleep(seconds_until) + + await self.bot.wait_until_guild_available() + if not self.webhook: + await self.bot.fetch_webhook(Webhooks.reddit) + + if datetime.utcnow().weekday() == 0: + await self.top_weekly_posts() + # if it's a monday send the top weekly posts + + for subreddit in RedditConfig.subreddits: + top_posts = await self.get_top_posts(subreddit=subreddit, time="day") + username = sub_clyde(f"{subreddit} Top Daily Posts") + message = await self.webhook.send(username=username, embed=top_posts, wait=True) + + if message.channel.is_news(): + await message.publish() + + async def top_weekly_posts(self) -> None: + """Post a summary of the top posts.""" + for subreddit in RedditConfig.subreddits: + # Send and pin the new weekly posts. + top_posts = await self.get_top_posts(subreddit=subreddit, time="week") + username = sub_clyde(f"{subreddit} Top Weekly Posts") + message = await self.webhook.send(wait=True, username=username, embed=top_posts) + + if subreddit.lower() == "r/python": + if not self.channel: + log.warning("Failed to get #reddit channel to remove pins in the weekly loop.") + return + + # Remove the oldest pins so that only 12 remain at most. + pins = await self.channel.pins() + + while len(pins) >= 12: + await pins[-1].unpin() + del pins[-1] + + await message.pin() + + if message.channel.is_news(): + await message.publish() + + @group(name="reddit", invoke_without_command=True) + async def reddit_group(self, ctx: Context) -> None: + """View the top posts from various subreddits.""" + await ctx.send_help(ctx.command) + + @reddit_group.command(name="top") + async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: + """Send the top posts of all time from a given subreddit.""" + async with ctx.typing(): + embed = await self.get_top_posts(subreddit=subreddit, time="all") + + await ctx.send(content=f"Here are the top {subreddit} posts of all time!", embed=embed) + + @reddit_group.command(name="daily") + async def daily_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: + """Send the top posts of today from a given subreddit.""" + async with ctx.typing(): + embed = await self.get_top_posts(subreddit=subreddit, time="day") + + await ctx.send(content=f"Here are today's top {subreddit} posts!", embed=embed) + + @reddit_group.command(name="weekly") + async def weekly_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: + """Send the top posts of this week from a given subreddit.""" + async with ctx.typing(): + embed = await self.get_top_posts(subreddit=subreddit, time="week") + + await ctx.send(content=f"Here are this week's top {subreddit} posts!", embed=embed) + + @with_role(*STAFF_ROLES) + @reddit_group.command(name="subreddits", aliases=("subs",)) + async def subreddits_command(self, ctx: Context) -> None: + """Send a paginated embed of all the subreddits we're relaying.""" + embed = Embed() + embed.title = "Relayed subreddits." + embed.colour = Colour.blurple() + + await LinePaginator.paginate( + RedditConfig.subreddits, + ctx, embed, + footer_text="Use the reddit commands along with these to view their posts.", + empty=False, + max_lines=15 + ) + + +def setup(bot: Bot) -> None: + """Load the Reddit cog.""" + if not RedditConfig.secret or not RedditConfig.client_id: + log.error("Credentials not provided, cog not loaded.") + return + bot.add_cog(Reddit(bot)) diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py new file mode 100644 index 000000000..ac29daa1d --- /dev/null +++ b/bot/exts/info/site.py @@ -0,0 +1,146 @@ +import logging + +from discord import Colour, Embed +from discord.ext.commands import Cog, Context, group + +from bot.bot import Bot +from bot.constants import URLs +from bot.pagination import LinePaginator + +log = logging.getLogger(__name__) + +PAGES_URL = f"{URLs.site_schema}{URLs.site}/pages" + + +class Site(Cog): + """Commands for linking to different parts of the site.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @group(name="site", aliases=("s",), invoke_without_command=True) + async def site_group(self, ctx: Context) -> None: + """Commands for getting info about our website.""" + await ctx.send_help(ctx.command) + + @site_group.command(name="home", aliases=("about",)) + async def site_main(self, ctx: Context) -> None: + """Info about the website itself.""" + url = f"{URLs.site_schema}{URLs.site}/" + + embed = Embed(title="Python Discord website") + embed.set_footer(text=url) + embed.colour = Colour.blurple() + embed.description = ( + f"[Our official website]({url}) is an open-source community project " + "created with Python and Django. It contains information about the server " + "itself, lets you sign up for upcoming events, has its own wiki, contains " + "a list of valuable learning resources, and much more." + ) + + await ctx.send(embed=embed) + + @site_group.command(name="resources") + async def site_resources(self, ctx: Context) -> None: + """Info about the site's Resources page.""" + learning_url = f"{PAGES_URL}/resources" + + embed = Embed(title="Resources") + embed.set_footer(text=f"{learning_url}") + embed.colour = Colour.blurple() + embed.description = ( + f"The [Resources page]({learning_url}) on our website contains a " + "list of hand-selected learning resources that we regularly recommend " + f"to both beginners and experts." + ) + + await ctx.send(embed=embed) + + @site_group.command(name="tools") + async def site_tools(self, ctx: Context) -> None: + """Info about the site's Tools page.""" + tools_url = f"{PAGES_URL}/resources/tools" + + embed = Embed(title="Tools") + embed.set_footer(text=f"{tools_url}") + embed.colour = Colour.blurple() + embed.description = ( + f"The [Tools page]({tools_url}) on our website contains a " + f"couple of the most popular tools for programming in Python." + ) + + await ctx.send(embed=embed) + + @site_group.command(name="help") + async def site_help(self, ctx: Context) -> None: + """Info about the site's Getting Help page.""" + url = f"{PAGES_URL}/resources/guides/asking-good-questions" + + embed = Embed(title="Asking Good Questions") + embed.set_footer(text=url) + embed.colour = Colour.blurple() + embed.description = ( + "Asking the right question about something that's new to you can sometimes be tricky. " + f"To help with this, we've created a [guide to asking good questions]({url}) on our website. " + "It contains everything you need to get the very best help from our community." + ) + + await ctx.send(embed=embed) + + @site_group.command(name="faq") + async def site_faq(self, ctx: Context) -> None: + """Info about the site's FAQ page.""" + url = f"{PAGES_URL}/frequently-asked-questions" + + embed = Embed(title="FAQ") + embed.set_footer(text=url) + embed.colour = Colour.blurple() + embed.description = ( + "As the largest Python community on Discord, we get hundreds of questions every day. " + "Many of these questions have been asked before. We've compiled a list of the most " + "frequently asked questions along with their answers, which can be found on " + f"our [FAQ page]({url})." + ) + + await ctx.send(embed=embed) + + @site_group.command(aliases=['r', 'rule'], name='rules') + async def site_rules(self, ctx: Context, *rules: int) -> None: + """Provides a link to all rules or, if specified, displays specific rule(s).""" + rules_embed = Embed(title='Rules', color=Colour.blurple()) + rules_embed.url = f"{PAGES_URL}/rules" + + if not rules: + # Rules were not submitted. Return the default description. + rules_embed.description = ( + "The rules and guidelines that apply to this community can be found on" + f" our [rules page]({PAGES_URL}/rules). We expect" + " all members of the community to have read and understood these." + ) + + await ctx.send(embed=rules_embed) + return + + full_rules = await self.bot.api_client.get('rules', params={'link_format': 'md'}) + invalid_indices = tuple( + pick + for pick in rules + if pick < 1 or pick > len(full_rules) + ) + + if invalid_indices: + indices = ', '.join(map(str, invalid_indices)) + await ctx.send(f":x: Invalid rule indices: {indices}") + return + + for rule in rules: + self.bot.stats.incr(f"rule_uses.{rule}") + + final_rules = tuple(f"**{pick}.** {full_rules[pick - 1]}" for pick in rules) + + await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3) + + +def setup(bot: Bot) -> None: + """Load the Site cog.""" + bot.add_cog(Site(bot)) diff --git a/bot/exts/info/source.py b/bot/exts/info/source.py new file mode 100644 index 000000000..205e0ba81 --- /dev/null +++ b/bot/exts/info/source.py @@ -0,0 +1,141 @@ +import inspect +from pathlib import Path +from typing import Optional, Tuple, Union + +from discord import Embed +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import URLs + +SourceType = Union[commands.HelpCommand, commands.Command, commands.Cog, str, commands.ExtensionNotLoaded] + + +class SourceConverter(commands.Converter): + """Convert an argument into a help command, tag, command, or cog.""" + + async def convert(self, ctx: commands.Context, argument: str) -> SourceType: + """Convert argument into source object.""" + if argument.lower().startswith("help"): + return ctx.bot.help_command + + cog = ctx.bot.get_cog(argument) + if cog: + return cog + + cmd = ctx.bot.get_command(argument) + if cmd: + return cmd + + tags_cog = ctx.bot.get_cog("Tags") + show_tag = True + + if not tags_cog: + show_tag = False + elif argument.lower() in tags_cog._cache: + return argument.lower() + + raise commands.BadArgument( + f"Unable to convert `{argument}` to valid command{', tag,' if show_tag else ''} or Cog." + ) + + +class BotSource(commands.Cog): + """Displays information about the bot's source code.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @commands.command(name="source", aliases=("src",)) + async def source_command(self, ctx: commands.Context, *, source_item: SourceConverter = None) -> None: + """Display information and a GitHub link to the source code of a command, tag, or cog.""" + if not source_item: + embed = Embed(title="Bot's GitHub Repository") + embed.add_field(name="Repository", value=f"[Go to GitHub]({URLs.github_bot_repo})") + embed.set_thumbnail(url="https://avatars1.githubusercontent.com/u/9919") + await ctx.send(embed=embed) + return + + embed = await self.build_embed(source_item) + await ctx.send(embed=embed) + + def get_source_link(self, source_item: SourceType) -> Tuple[str, str, Optional[int]]: + """ + Build GitHub link of source item, return this link, file location and first line number. + + Raise BadArgument if `source_item` is a dynamically-created object (e.g. via internal eval). + """ + if isinstance(source_item, commands.Command): + if source_item.cog_name == "Alias": + cmd_name = source_item.callback.__name__.replace("_alias", "") + cmd = self.bot.get_command(cmd_name.replace("_", " ")) + src = cmd.callback.__code__ + filename = src.co_filename + else: + src = source_item.callback.__code__ + filename = src.co_filename + elif isinstance(source_item, str): + tags_cog = self.bot.get_cog("Tags") + filename = tags_cog._cache[source_item]["location"] + else: + src = type(source_item) + try: + filename = inspect.getsourcefile(src) + except TypeError: + raise commands.BadArgument("Cannot get source for a dynamically-created object.") + + if not isinstance(source_item, str): + try: + lines, first_line_no = inspect.getsourcelines(src) + except OSError: + raise commands.BadArgument("Cannot get source for a dynamically-created object.") + + lines_extension = f"#L{first_line_no}-L{first_line_no+len(lines)-1}" + else: + first_line_no = None + lines_extension = "" + + # Handle tag file location differently than others to avoid errors in some cases + if not first_line_no: + file_location = Path(filename).relative_to("/bot/") + else: + file_location = Path(filename).relative_to(Path.cwd()).as_posix() + + url = f"{URLs.github_bot_repo}/blob/master/{file_location}{lines_extension}" + + return url, file_location, first_line_no or None + + async def build_embed(self, source_object: SourceType) -> Optional[Embed]: + """Build embed based on source object.""" + url, location, first_line = self.get_source_link(source_object) + + if isinstance(source_object, commands.HelpCommand): + title = "Help Command" + description = source_object.__doc__.splitlines()[1] + elif isinstance(source_object, commands.Command): + if source_object.cog_name == "Alias": + cmd_name = source_object.callback.__name__.replace("_alias", "") + cmd = self.bot.get_command(cmd_name.replace("_", " ")) + description = cmd.short_doc + else: + description = source_object.short_doc + + title = f"Command: {source_object.qualified_name}" + elif isinstance(source_object, str): + title = f"Tag: {source_object}" + description = "" + else: + title = f"Cog: {source_object.qualified_name}" + description = source_object.description.splitlines()[0] + + embed = Embed(title=title, description=description) + embed.add_field(name="Source Code", value=f"[Go to GitHub]({url})") + line_text = f":{first_line}" if first_line else "" + embed.set_footer(text=f"{location}{line_text}") + + return embed + + +def setup(bot: Bot) -> None: + """Load the BotSource cog.""" + bot.add_cog(BotSource(bot)) diff --git a/bot/exts/info/stats.py b/bot/exts/info/stats.py new file mode 100644 index 000000000..d42f55466 --- /dev/null +++ b/bot/exts/info/stats.py @@ -0,0 +1,129 @@ +import string +from datetime import datetime + +from discord import Member, Message, Status +from discord.ext.commands import Cog, Context +from discord.ext.tasks import loop + +from bot.bot import Bot +from bot.constants import Categories, Channels, Guild, Stats as StatConf + + +CHANNEL_NAME_OVERRIDES = { + Channels.off_topic_0: "off_topic_0", + Channels.off_topic_1: "off_topic_1", + Channels.off_topic_2: "off_topic_2", + Channels.staff_lounge: "staff_lounge" +} + +ALLOWED_CHARS = string.ascii_letters + string.digits + "_" + + +class Stats(Cog): + """A cog which provides a way to hook onto Discord events and forward to stats.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.last_presence_update = None + self.update_guild_boost.start() + + @Cog.listener() + async def on_message(self, message: Message) -> None: + """Report message events in the server to statsd.""" + if message.guild is None: + return + + if message.guild.id != Guild.id: + return + + cat = getattr(message.channel, "category", None) + if cat is not None and cat.id == Categories.modmail: + if message.channel.id != Channels.incidents: + # Do not report modmail channels to stats, there are too many + # of them for interesting statistics to be drawn out of this. + return + + reformatted_name = message.channel.name.replace('-', '_') + + if CHANNEL_NAME_OVERRIDES.get(message.channel.id): + reformatted_name = CHANNEL_NAME_OVERRIDES.get(message.channel.id) + + reformatted_name = "".join(char for char in reformatted_name if char in ALLOWED_CHARS) + + stat_name = f"channels.{reformatted_name}" + self.bot.stats.incr(stat_name) + + # Increment the total message count + self.bot.stats.incr("messages") + + @Cog.listener() + async def on_command_completion(self, ctx: Context) -> None: + """Report completed commands to statsd.""" + command_name = ctx.command.qualified_name.replace(" ", "_") + + self.bot.stats.incr(f"commands.{command_name}") + + @Cog.listener() + async def on_member_join(self, member: Member) -> None: + """Update member count stat on member join.""" + if member.guild.id != Guild.id: + return + + self.bot.stats.gauge("guild.total_members", len(member.guild.members)) + + @Cog.listener() + async def on_member_leave(self, member: Member) -> None: + """Update member count stat on member leave.""" + if member.guild.id != Guild.id: + return + + self.bot.stats.gauge("guild.total_members", len(member.guild.members)) + + @Cog.listener() + async def on_member_update(self, _before: Member, after: Member) -> None: + """Update presence estimates on member update.""" + if after.guild.id != Guild.id: + return + + if self.last_presence_update: + if (datetime.now() - self.last_presence_update).seconds < StatConf.presence_update_timeout: + return + + self.last_presence_update = datetime.now() + + online = 0 + idle = 0 + dnd = 0 + offline = 0 + + for member in after.guild.members: + if member.status is Status.online: + online += 1 + elif member.status is Status.dnd: + dnd += 1 + elif member.status is Status.idle: + idle += 1 + elif member.status is Status.offline: + offline += 1 + + self.bot.stats.gauge("guild.status.online", online) + self.bot.stats.gauge("guild.status.idle", idle) + self.bot.stats.gauge("guild.status.do_not_disturb", dnd) + self.bot.stats.gauge("guild.status.offline", offline) + + @loop(hours=1) + async def update_guild_boost(self) -> None: + """Post the server boost level and tier every hour.""" + await self.bot.wait_until_guild_available() + g = self.bot.get_guild(Guild.id) + self.bot.stats.gauge("boost.amount", g.premium_subscription_count) + self.bot.stats.gauge("boost.tier", g.premium_tier) + + def cog_unload(self) -> None: + """Stop the boost statistic task on unload of the Cog.""" + self.update_guild_boost.stop() + + +def setup(bot: Bot) -> None: + """Load the stats cog.""" + bot.add_cog(Stats(bot)) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py new file mode 100644 index 000000000..3d76c5c08 --- /dev/null +++ b/bot/exts/info/tags.py @@ -0,0 +1,277 @@ +import logging +import re +import time +from pathlib import Path +from typing import Callable, Dict, Iterable, List, Optional + +from discord import Colour, Embed, Member +from discord.ext.commands import Cog, Context, group + +from bot import constants +from bot.bot import Bot +from bot.converters import TagNameConverter +from bot.pagination import LinePaginator +from bot.utils.messages import wait_for_deletion + +log = logging.getLogger(__name__) + +TEST_CHANNELS = ( + constants.Channels.bot_commands, + constants.Channels.helpers +) + +REGEX_NON_ALPHABET = re.compile(r"[^a-z]", re.MULTILINE & re.IGNORECASE) +FOOTER_TEXT = f"To show a tag, type {constants.Bot.prefix}tags ." + + +class Tags(Cog): + """Save new tags and fetch existing tags.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.tag_cooldowns = {} + self._cache = self.get_tags() + + @staticmethod + def get_tags() -> dict: + """Get all tags.""" + cache = {} + + base_path = Path("bot", "resources", "tags") + for file in base_path.glob("**/*"): + if file.is_file(): + tag_title = file.stem + tag = { + "title": tag_title, + "embed": { + "description": file.read_text(encoding="utf8"), + }, + "restricted_to": "developers", + "location": f"/bot/{file}" + } + + # Convert to a list to allow negative indexing. + parents = list(file.relative_to(base_path).parents) + if len(parents) > 1: + # -1 would be '.' hence -2 is used as the index. + tag["restricted_to"] = parents[-2].name + + cache[tag_title] = tag + + return cache + + @staticmethod + def check_accessibility(user: Member, tag: dict) -> bool: + """Check if user can access a tag.""" + return tag["restricted_to"].lower() in [role.name.lower() for role in user.roles] + + @staticmethod + def _fuzzy_search(search: str, target: str) -> float: + """A simple scoring algorithm based on how many letters are found / total, with order in mind.""" + current, index = 0, 0 + _search = REGEX_NON_ALPHABET.sub('', search.lower()) + _targets = iter(REGEX_NON_ALPHABET.split(target.lower())) + _target = next(_targets) + try: + while True: + while index < len(_target) and _search[current] == _target[index]: + current += 1 + index += 1 + index, _target = 0, next(_targets) + except (StopIteration, IndexError): + pass + return current / len(_search) * 100 + + def _get_suggestions(self, tag_name: str, thresholds: Optional[List[int]] = None) -> List[str]: + """Return a list of suggested tags.""" + scores: Dict[str, int] = { + tag_title: Tags._fuzzy_search(tag_name, tag['title']) + for tag_title, tag in self._cache.items() + } + + thresholds = thresholds or [100, 90, 80, 70, 60] + + for threshold in thresholds: + suggestions = [ + self._cache[tag_title] + for tag_title, matching_score in scores.items() + if matching_score >= threshold + ] + if suggestions: + return suggestions + + return [] + + def _get_tag(self, tag_name: str) -> list: + """Get a specific tag.""" + found = [self._cache.get(tag_name.lower(), None)] + if not found[0]: + return self._get_suggestions(tag_name) + return found + + def _get_tags_via_content(self, check: Callable[[Iterable], bool], keywords: str, user: Member) -> list: + """ + Search for tags via contents. + + `predicate` will be the built-in any, all, or a custom callable. Must return a bool. + """ + keywords_processed: List[str] = [] + for keyword in keywords.split(','): + keyword_sanitized = keyword.strip().casefold() + if not keyword_sanitized: + # this happens when there are leading / trailing / consecutive comma. + continue + keywords_processed.append(keyword_sanitized) + + if not keywords_processed: + # after sanitizing, we can end up with an empty list, for example when keywords is ',' + # in that case, we simply want to search for such keywords directly instead. + keywords_processed = [keywords] + + matching_tags = [] + for tag in self._cache.values(): + matches = (query in tag['embed']['description'].casefold() for query in keywords_processed) + if self.check_accessibility(user, tag) and check(matches): + matching_tags.append(tag) + + return matching_tags + + async def _send_matching_tags(self, ctx: Context, keywords: str, matching_tags: list) -> None: + """Send the result of matching tags to user.""" + if not matching_tags: + pass + elif len(matching_tags) == 1: + await ctx.send(embed=Embed().from_dict(matching_tags[0]['embed'])) + else: + is_plural = keywords.strip().count(' ') > 0 or keywords.strip().count(',') > 0 + embed = Embed( + title=f"Here are the tags containing the given keyword{'s' * is_plural}:", + description='\n'.join(tag['title'] for tag in matching_tags[:10]) + ) + await LinePaginator.paginate( + sorted(f"**»** {tag['title']}" for tag in matching_tags), + ctx, + embed, + footer_text=FOOTER_TEXT, + empty=False, + max_lines=15 + ) + + @group(name='tags', aliases=('tag', 't'), invoke_without_command=True) + async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: + """Show all known tags, a single tag, or run a subcommand.""" + await ctx.invoke(self.get_command, tag_name=tag_name) + + @tags_group.group(name='search', invoke_without_command=True) + async def search_tag_content(self, ctx: Context, *, keywords: str) -> None: + """ + Search inside tags' contents for tags. Allow searching for multiple keywords separated by comma. + + Only search for tags that has ALL the keywords. + """ + matching_tags = self._get_tags_via_content(all, keywords, ctx.author) + await self._send_matching_tags(ctx, keywords, matching_tags) + + @search_tag_content.command(name='any') + async def search_tag_content_any_keyword(self, ctx: Context, *, keywords: Optional[str] = 'any') -> None: + """ + Search inside tags' contents for tags. Allow searching for multiple keywords separated by comma. + + Search for tags that has ANY of the keywords. + """ + matching_tags = self._get_tags_via_content(any, keywords or 'any', ctx.author) + await self._send_matching_tags(ctx, keywords, matching_tags) + + @tags_group.command(name='get', aliases=('show', 'g')) + async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: + """Get a specified tag, or a list of all tags if no tag is specified.""" + + def _command_on_cooldown(tag_name: str) -> bool: + """ + Check if the command is currently on cooldown, on a per-tag, per-channel basis. + + The cooldown duration is set in constants.py. + """ + now = time.time() + + cooldown_conditions = ( + tag_name + and tag_name in self.tag_cooldowns + and (now - self.tag_cooldowns[tag_name]["time"]) < constants.Cooldowns.tags + and self.tag_cooldowns[tag_name]["channel"] == ctx.channel.id + ) + + if cooldown_conditions: + return True + return False + + if _command_on_cooldown(tag_name): + time_elapsed = time.time() - self.tag_cooldowns[tag_name]["time"] + time_left = constants.Cooldowns.tags - time_elapsed + log.info( + f"{ctx.author} tried to get the '{tag_name}' tag, but the tag is on cooldown. " + f"Cooldown ends in {time_left:.1f} seconds." + ) + return + + if tag_name is not None: + temp_founds = self._get_tag(tag_name) + + founds = [] + + for found_tag in temp_founds: + if self.check_accessibility(ctx.author, found_tag): + founds.append(found_tag) + + if len(founds) == 1: + tag = founds[0] + if ctx.channel.id not in TEST_CHANNELS: + self.tag_cooldowns[tag_name] = { + "time": time.time(), + "channel": ctx.channel.id + } + + self.bot.stats.incr(f"tags.usages.{tag['title'].replace('-', '_')}") + + await wait_for_deletion( + await ctx.send(embed=Embed.from_dict(tag['embed'])), + [ctx.author.id], + client=self.bot + ) + elif founds and len(tag_name) >= 3: + await wait_for_deletion( + await ctx.send( + embed=Embed( + title='Did you mean ...', + description='\n'.join(tag['title'] for tag in founds[:10]) + ) + ), + [ctx.author.id], + client=self.bot + ) + + else: + tags = self._cache.values() + if not tags: + await ctx.send(embed=Embed( + description="**There are no tags in the database!**", + colour=Colour.red() + )) + else: + embed: Embed = Embed(title="**Current tags**") + await LinePaginator.paginate( + sorted( + f"**»** {tag['title']}" for tag in tags + if self.check_accessibility(ctx.author, tag) + ), + ctx, + embed, + footer_text=FOOTER_TEXT, + empty=False, + max_lines=15 + ) + + +def setup(bot: Bot) -> None: + """Load the Tags cog.""" + bot.add_cog(Tags(bot)) diff --git a/bot/exts/info/wolfram.py b/bot/exts/info/wolfram.py new file mode 100644 index 000000000..e6cae3bb8 --- /dev/null +++ b/bot/exts/info/wolfram.py @@ -0,0 +1,280 @@ +import logging +from io import BytesIO +from typing import Callable, List, Optional, Tuple +from urllib import parse + +import discord +from dateutil.relativedelta import relativedelta +from discord import Embed +from discord.ext import commands +from discord.ext.commands import BucketType, Cog, Context, check, group + +from bot.bot import Bot +from bot.constants import Colours, STAFF_ROLES, Wolfram +from bot.pagination import ImagePaginator +from bot.utils.time import humanize_delta + +log = logging.getLogger(__name__) + +APPID = Wolfram.key +DEFAULT_OUTPUT_FORMAT = "JSON" +QUERY = "http://api.wolframalpha.com/v2/{request}?{data}" +WOLF_IMAGE = "https://www.symbols.com/gi.php?type=1&id=2886&i=1" + +MAX_PODS = 20 + +# Allows for 10 wolfram calls pr user pr day +usercd = commands.CooldownMapping.from_cooldown(Wolfram.user_limit_day, 60*60*24, BucketType.user) + +# Allows for max api requests / days in month per day for the entire guild (Temporary) +guildcd = commands.CooldownMapping.from_cooldown(Wolfram.guild_limit_day, 60*60*24, BucketType.guild) + + +async def send_embed( + ctx: Context, + message_txt: str, + colour: int = Colours.soft_red, + footer: str = None, + img_url: str = None, + f: discord.File = None +) -> None: + """Generate & send a response embed with Wolfram as the author.""" + embed = Embed(colour=colour) + embed.description = message_txt + embed.set_author(name="Wolfram Alpha", + icon_url=WOLF_IMAGE, + url="https://www.wolframalpha.com/") + if footer: + embed.set_footer(text=footer) + + if img_url: + embed.set_image(url=img_url) + + await ctx.send(embed=embed, file=f) + + +def custom_cooldown(*ignore: List[int]) -> Callable: + """ + Implement per-user and per-guild cooldowns for requests to the Wolfram API. + + A list of roles may be provided to ignore the per-user cooldown + """ + async def predicate(ctx: Context) -> bool: + if ctx.invoked_with == 'help': + # if the invoked command is help we don't want to increase the ratelimits since it's not actually + # invoking the command/making a request, so instead just check if the user/guild are on cooldown. + guild_cooldown = not guildcd.get_bucket(ctx.message).get_tokens() == 0 # if guild is on cooldown + if not any(r.id in ignore for r in ctx.author.roles): # check user bucket if user is not ignored + return guild_cooldown and not usercd.get_bucket(ctx.message).get_tokens() == 0 + return guild_cooldown + + user_bucket = usercd.get_bucket(ctx.message) + + if all(role.id not in ignore for role in ctx.author.roles): + user_rate = user_bucket.update_rate_limit() + + if user_rate: + # Can't use api; cause: member limit + delta = relativedelta(seconds=int(user_rate)) + cooldown = humanize_delta(delta) + message = ( + "You've used up your limit for Wolfram|Alpha requests.\n" + f"Cooldown: {cooldown}" + ) + await send_embed(ctx, message) + return False + + guild_bucket = guildcd.get_bucket(ctx.message) + guild_rate = guild_bucket.update_rate_limit() + + # Repr has a token attribute to read requests left + log.debug(guild_bucket) + + if guild_rate: + # Can't use api; cause: guild limit + message = ( + "The max limit of requests for the server has been reached for today.\n" + f"Cooldown: {int(guild_rate)}" + ) + await send_embed(ctx, message) + return False + + return True + return check(predicate) + + +async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[List[Tuple]]: + """Get the Wolfram API pod pages for the provided query.""" + async with ctx.channel.typing(): + url_str = parse.urlencode({ + "input": query, + "appid": APPID, + "output": DEFAULT_OUTPUT_FORMAT, + "format": "image,plaintext" + }) + request_url = QUERY.format(request="query", data=url_str) + + async with bot.http_session.get(request_url) as response: + json = await response.json(content_type='text/plain') + + result = json["queryresult"] + + if result["error"]: + # API key not set up correctly + if result["error"]["msg"] == "Invalid appid": + message = "Wolfram API key is invalid or missing." + log.warning( + "API key seems to be missing, or invalid when " + f"processing a wolfram request: {url_str}, Response: {json}" + ) + await send_embed(ctx, message) + return + + message = "Something went wrong internally with your request, please notify staff!" + log.warning(f"Something went wrong getting a response from wolfram: {url_str}, Response: {json}") + await send_embed(ctx, message) + return + + if not result["success"]: + message = f"I couldn't find anything for {query}." + await send_embed(ctx, message) + return + + if not result["numpods"]: + message = "Could not find any results." + await send_embed(ctx, message) + return + + pods = result["pods"] + pages = [] + for pod in pods[:MAX_PODS]: + subs = pod.get("subpods") + + for sub in subs: + title = sub.get("title") or sub.get("plaintext") or sub.get("id", "") + img = sub["img"]["src"] + pages.append((title, img)) + return pages + + +class Wolfram(Cog): + """Commands for interacting with the Wolfram|Alpha API.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @group(name="wolfram", aliases=("wolf", "wa"), invoke_without_command=True) + @custom_cooldown(*STAFF_ROLES) + async def wolfram_command(self, ctx: Context, *, query: str) -> None: + """Requests all answers on a single image, sends an image of all related pods.""" + url_str = parse.urlencode({ + "i": query, + "appid": APPID, + }) + query = QUERY.format(request="simple", data=url_str) + + # Give feedback that the bot is working. + async with ctx.channel.typing(): + async with self.bot.http_session.get(query) as response: + status = response.status + image_bytes = await response.read() + + f = discord.File(BytesIO(image_bytes), filename="image.png") + image_url = "attachment://image.png" + + if status == 501: + message = "Failed to get response" + footer = "" + color = Colours.soft_red + elif status == 400: + message = "No input found" + footer = "" + color = Colours.soft_red + elif status == 403: + message = "Wolfram API key is invalid or missing." + footer = "" + color = Colours.soft_red + else: + message = "" + footer = "View original for a bigger picture." + color = Colours.soft_orange + + # Sends a "blank" embed if no request is received, unsure how to fix + await send_embed(ctx, message, color, footer=footer, img_url=image_url, f=f) + + @wolfram_command.command(name="page", aliases=("pa", "p")) + @custom_cooldown(*STAFF_ROLES) + async def wolfram_page_command(self, ctx: Context, *, query: str) -> None: + """ + Requests a drawn image of given query. + + Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. + """ + pages = await get_pod_pages(ctx, self.bot, query) + + if not pages: + return + + embed = Embed() + embed.set_author(name="Wolfram Alpha", + icon_url=WOLF_IMAGE, + url="https://www.wolframalpha.com/") + embed.colour = Colours.soft_orange + + await ImagePaginator.paginate(pages, ctx, embed) + + @wolfram_command.command(name="cut", aliases=("c",)) + @custom_cooldown(*STAFF_ROLES) + async def wolfram_cut_command(self, ctx: Context, *, query: str) -> None: + """ + Requests a drawn image of given query. + + Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. + """ + pages = await get_pod_pages(ctx, self.bot, query) + + if not pages: + return + + if len(pages) >= 2: + page = pages[1] + else: + page = pages[0] + + await send_embed(ctx, page[0], colour=Colours.soft_orange, img_url=page[1]) + + @wolfram_command.command(name="short", aliases=("sh", "s")) + @custom_cooldown(*STAFF_ROLES) + async def wolfram_short_command(self, ctx: Context, *, query: str) -> None: + """Requests an answer to a simple question.""" + url_str = parse.urlencode({ + "i": query, + "appid": APPID, + }) + query = QUERY.format(request="result", data=url_str) + + # Give feedback that the bot is working. + async with ctx.channel.typing(): + async with self.bot.http_session.get(query) as response: + status = response.status + response_text = await response.text() + + if status == 501: + message = "Failed to get response" + color = Colours.soft_red + elif status == 400: + message = "No input found" + color = Colours.soft_red + elif response_text == "Error 1: Invalid appid": + message = "Wolfram API key is invalid or missing." + color = Colours.soft_red + else: + message = response_text + color = Colours.soft_orange + + await send_embed(ctx, message, color) + + +def setup(bot: Bot) -> None: + """Load the Wolfram cog.""" + bot.add_cog(Wolfram(bot)) diff --git a/bot/exts/moderation/__init__.py b/bot/exts/moderation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py new file mode 100644 index 000000000..b75a4dcfe --- /dev/null +++ b/bot/exts/moderation/defcon.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +import logging +from collections import namedtuple +from datetime import datetime, timedelta +from enum import Enum + +from discord import Colour, Embed, Member +from discord.ext.commands import Cog, Context, group + +from bot.bot import Bot +from bot.constants import Channels, Colours, Emojis, Event, Icons, Roles +from bot.decorators import with_role +from bot.exts.moderation.modlog import ModLog + +log = logging.getLogger(__name__) + +REJECTION_MESSAGE = """ +Hi, {user} - Thanks for your interest in our server! + +Due to a current (or detected) cyberattack on our community, we've limited access to the server for new accounts. Since +your account is relatively new, we're unable to provide access to the server at this time. + +Even so, thanks for joining! We're very excited at the possibility of having you here, and we hope that this situation +will be resolved soon. In the meantime, please feel free to peruse the resources on our site at +, and have a nice day! +""" + +BASE_CHANNEL_TOPIC = "Python Discord Defense Mechanism" + + +class Action(Enum): + """Defcon Action.""" + + ActionInfo = namedtuple('LogInfoDetails', ['icon', 'color', 'template']) + + ENABLED = ActionInfo(Icons.defcon_enabled, Colours.soft_green, "**Days:** {days}\n\n") + DISABLED = ActionInfo(Icons.defcon_disabled, Colours.soft_red, "") + UPDATED = ActionInfo(Icons.defcon_updated, Colour.blurple(), "**Days:** {days}\n\n") + + +class Defcon(Cog): + """Time-sensitive server defense mechanisms.""" + + days = None # type: timedelta + enabled = False # type: bool + + def __init__(self, bot: Bot): + self.bot = bot + self.channel = None + self.days = timedelta(days=0) + + self.bot.loop.create_task(self.sync_settings()) + + @property + def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + async def sync_settings(self) -> None: + """On cog load, try to synchronize DEFCON settings to the API.""" + await self.bot.wait_until_guild_available() + self.channel = await self.bot.fetch_channel(Channels.defcon) + + try: + response = await self.bot.api_client.get('bot/bot-settings/defcon') + data = response['data'] + + except Exception: # Yikes! + log.exception("Unable to get DEFCON settings!") + await self.bot.get_channel(Channels.dev_log).send( + f"<@&{Roles.admins}> **WARNING**: Unable to get DEFCON settings!" + ) + + else: + if data["enabled"]: + self.enabled = True + self.days = timedelta(days=data["days"]) + log.info(f"DEFCON enabled: {self.days.days} days") + + else: + self.enabled = False + self.days = timedelta(days=0) + log.info("DEFCON disabled") + + await self.update_channel_topic() + + @Cog.listener() + async def on_member_join(self, member: Member) -> None: + """If DEFCON is enabled, check newly joining users to see if they meet the account age threshold.""" + if self.enabled and self.days.days > 0: + now = datetime.utcnow() + + if now - member.created_at < self.days: + log.info(f"Rejecting user {member}: Account is too new and DEFCON is enabled") + + message_sent = False + + try: + await member.send(REJECTION_MESSAGE.format(user=member.mention)) + + message_sent = True + except Exception: + log.exception(f"Unable to send rejection message to user: {member}") + + await member.kick(reason="DEFCON active, user is too new") + self.bot.stats.incr("defcon.leaves") + + message = ( + f"{member} (`{member.id}`) was denied entry because their account is too new." + ) + + if not message_sent: + message = f"{message}\n\nUnable to send rejection message via DM; they probably have DMs disabled." + + await self.mod_log.send_log_message( + Icons.defcon_denied, Colours.soft_red, "Entry denied", + message, member.avatar_url_as(static_format="png") + ) + + @group(name='defcon', aliases=('dc',), invoke_without_command=True) + @with_role(Roles.admins, Roles.owners) + async def defcon_group(self, ctx: Context) -> None: + """Check the DEFCON status or run a subcommand.""" + await ctx.send_help(ctx.command) + + async def _defcon_action(self, ctx: Context, days: int, action: Action) -> None: + """Providing a structured way to do an defcon action.""" + try: + response = await self.bot.api_client.get('bot/bot-settings/defcon') + data = response['data'] + + if "enable_date" in data and action is Action.DISABLED: + enabled = datetime.fromisoformat(data["enable_date"]) + + delta = datetime.now() - enabled + + self.bot.stats.timing("defcon.enabled", delta) + except Exception: + pass + + error = None + try: + await self.bot.api_client.put( + 'bot/bot-settings/defcon', + json={ + 'name': 'defcon', + 'data': { + # TODO: retrieve old days count + 'days': days, + 'enabled': action is not Action.DISABLED, + 'enable_date': datetime.now().isoformat() + } + } + ) + except Exception as err: + log.exception("Unable to update DEFCON settings.") + error = err + finally: + await ctx.send(self.build_defcon_msg(action, error)) + await self.send_defcon_log(action, ctx.author, error) + + self.bot.stats.gauge("defcon.threshold", days) + + @defcon_group.command(name='enable', aliases=('on', 'e')) + @with_role(Roles.admins, Roles.owners) + async def enable_command(self, ctx: Context) -> None: + """ + Enable DEFCON mode. Useful in a pinch, but be sure you know what you're doing! + + Currently, this just adds an account age requirement. Use !defcon days to set how old an account must be, + in days. + """ + self.enabled = True + await self._defcon_action(ctx, days=0, action=Action.ENABLED) + await self.update_channel_topic() + + @defcon_group.command(name='disable', aliases=('off', 'd')) + @with_role(Roles.admins, Roles.owners) + async def disable_command(self, ctx: Context) -> None: + """Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!""" + self.enabled = False + await self._defcon_action(ctx, days=0, action=Action.DISABLED) + await self.update_channel_topic() + + @defcon_group.command(name='status', aliases=('s',)) + @with_role(Roles.admins, Roles.owners) + async def status_command(self, ctx: Context) -> None: + """Check the current status of DEFCON mode.""" + embed = Embed( + colour=Colour.blurple(), title="DEFCON Status", + description=f"**Enabled:** {self.enabled}\n" + f"**Days:** {self.days.days}" + ) + + await ctx.send(embed=embed) + + @defcon_group.command(name='days') + @with_role(Roles.admins, Roles.owners) + async def days_command(self, ctx: Context, days: int) -> None: + """Set how old an account must be to join the server, in days, with DEFCON mode enabled.""" + self.days = timedelta(days=days) + self.enabled = True + await self._defcon_action(ctx, days=days, action=Action.UPDATED) + await self.update_channel_topic() + + async def update_channel_topic(self) -> None: + """Update the #defcon channel topic with the current DEFCON status.""" + if self.enabled: + day_str = "days" if self.days.days > 1 else "day" + new_topic = f"{BASE_CHANNEL_TOPIC}\n(Status: Enabled, Threshold: {self.days.days} {day_str})" + else: + new_topic = f"{BASE_CHANNEL_TOPIC}\n(Status: Disabled)" + + self.mod_log.ignore(Event.guild_channel_update, Channels.defcon) + await self.channel.edit(topic=new_topic) + + def build_defcon_msg(self, action: Action, e: Exception = None) -> str: + """Build in-channel response string for DEFCON action.""" + if action is Action.ENABLED: + msg = f"{Emojis.defcon_enabled} DEFCON enabled.\n\n" + elif action is Action.DISABLED: + msg = f"{Emojis.defcon_disabled} DEFCON disabled.\n\n" + elif action is Action.UPDATED: + msg = ( + f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {self.days.days} " + f"day{'s' if self.days.days > 1 else ''} old to join the server.\n\n" + ) + + if e: + msg += ( + "**There was a problem updating the site** - This setting may be reverted when the bot restarts.\n\n" + f"```py\n{e}\n```" + ) + + return msg + + async def send_defcon_log(self, action: Action, actor: Member, e: Exception = None) -> None: + """Send log message for DEFCON action.""" + info = action.value + log_msg: str = ( + f"**Staffer:** {actor.mention} {actor} (`{actor.id}`)\n" + f"{info.template.format(days=self.days.days)}" + ) + status_msg = f"DEFCON {action.name.lower()}" + + if e: + log_msg += ( + "**There was a problem updating the site** - This setting may be reverted when the bot restarts.\n\n" + f"```py\n{e}\n```" + ) + + await self.mod_log.send_log_message(info.icon, info.color, status_msg, log_msg) + + +def setup(bot: Bot) -> None: + """Load the Defcon cog.""" + bot.add_cog(Defcon(bot)) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py new file mode 100644 index 000000000..e49913552 --- /dev/null +++ b/bot/exts/moderation/incidents.py @@ -0,0 +1,412 @@ +import asyncio +import logging +import typing as t +from datetime import datetime +from enum import Enum + +import discord +from discord.ext.commands import Cog + +from bot.bot import Bot +from bot.constants import Channels, Colours, Emojis, Guild, Webhooks +from bot.utils.messages import sub_clyde + +log = logging.getLogger(__name__) + +# Amount of messages for `crawl_task` to process at most on start-up - limited to 50 +# as in practice, there should never be this many messages, and if there are, +# something has likely gone very wrong +CRAWL_LIMIT = 50 + +# Seconds for `crawl_task` to sleep after adding reactions to a message +CRAWL_SLEEP = 2 + + +class Signal(Enum): + """ + Recognized incident status signals. + + This binds emoji to actions. The bot will only react to emoji linked here. + All other signals are seen as invalid. + """ + + ACTIONED = Emojis.incident_actioned + NOT_ACTIONED = Emojis.incident_unactioned + INVESTIGATING = Emojis.incident_investigating + + +# Reactions from non-mod roles will be removed +ALLOWED_ROLES: t.Set[int] = set(Guild.moderation_roles) + +# Message must have all of these emoji to pass the `has_signals` check +ALL_SIGNALS: t.Set[str] = {signal.value for signal in Signal} + +# An embed coupled with an optional file to be dispatched +# If the file is not None, the embed attempts to show it in its body +FileEmbed = t.Tuple[discord.Embed, t.Optional[discord.File]] + + +async def download_file(attachment: discord.Attachment) -> t.Optional[discord.File]: + """ + Download & return `attachment` file. + + If the download fails, the reason is logged and None will be returned. + 404 and 403 errors are only logged at debug level. + """ + log.debug(f"Attempting to download attachment: {attachment.filename}") + try: + return await attachment.to_file() + except (discord.NotFound, discord.Forbidden) as exc: + log.debug(f"Failed to download attachment: {exc}") + except Exception: + log.exception("Failed to download attachment") + + +async def make_embed(incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> FileEmbed: + """ + Create an embed representation of `incident` for the #incidents-archive channel. + + The name & discriminator of `actioned_by` and `outcome` will be presented in the + embed footer. Additionally, the embed is coloured based on `outcome`. + + The author of `incident` is not shown in the embed. It is assumed that this piece + of information will be relayed in other ways, e.g. webhook username. + + As mentions in embeds do not ping, we do not need to use `incident.clean_content`. + + If `incident` contains attachments, the first attachment will be downloaded and + returned alongside the embed. The embed attempts to display the attachment. + Should the download fail, we fallback on linking the `proxy_url`, which should + remain functional for some time after the original message is deleted. + """ + log.trace(f"Creating embed for {incident.id=}") + + if outcome is Signal.ACTIONED: + colour = Colours.soft_green + footer = f"Actioned by {actioned_by}" + else: + colour = Colours.soft_red + footer = f"Rejected by {actioned_by}" + + embed = discord.Embed( + description=incident.content, + timestamp=datetime.utcnow(), + colour=colour, + ) + embed.set_footer(text=footer, icon_url=actioned_by.avatar_url) + + if incident.attachments: + attachment = incident.attachments[0] # User-sent messages can only contain one attachment + file = await download_file(attachment) + + if file is not None: + embed.set_image(url=f"attachment://{attachment.filename}") # Embed displays the attached file + else: + embed.set_author(name="[Failed to relay attachment]", url=attachment.proxy_url) # Embed links the file + else: + file = None + + return embed, file + + +def is_incident(message: discord.Message) -> bool: + """True if `message` qualifies as an incident, False otherwise.""" + conditions = ( + message.channel.id == Channels.incidents, # Message sent in #incidents + not message.author.bot, # Not by a bot + not message.content.startswith("#"), # Doesn't start with a hash + not message.pinned, # And isn't header + ) + return all(conditions) + + +def own_reactions(message: discord.Message) -> t.Set[str]: + """Get the set of reactions placed on `message` by the bot itself.""" + return {str(reaction.emoji) for reaction in message.reactions if reaction.me} + + +def has_signals(message: discord.Message) -> bool: + """True if `message` already has all `Signal` reactions, False otherwise.""" + return ALL_SIGNALS.issubset(own_reactions(message)) + + +async def add_signals(incident: discord.Message) -> None: + """ + Add `Signal` member emoji to `incident` as reactions. + + If the emoji has already been placed on `incident` by the bot, it will be skipped. + """ + existing_reacts = own_reactions(incident) + + for signal_emoji in Signal: + if signal_emoji.value in existing_reacts: # This would not raise, but it is a superfluous API call + log.trace(f"Skipping emoji as it's already been placed: {signal_emoji}") + else: + log.trace(f"Adding reaction: {signal_emoji}") + await incident.add_reaction(signal_emoji.value) + + +class Incidents(Cog): + """ + Automation for the #incidents channel. + + This cog does not provide a command API, it only reacts to the following events. + + On start-up: + * Crawl #incidents and add missing `Signal` emoji where appropriate + * This is to retro-actively add the available options for messages which + were sent while the bot wasn't listening + * Pinned messages and message starting with # do not qualify as incidents + * See: `crawl_incidents` + + On message: + * Add `Signal` member emoji if message qualifies as an incident + * Ignore messages starting with # + * Use this if verbal communication is necessary + * Each such message must be deleted manually once appropriate + * See: `on_message` + + On reaction: + * Remove reaction if not permitted + * User does not have any of the roles in `ALLOWED_ROLES` + * Used emoji is not a `Signal` member + * If `Signal.ACTIONED` or `Signal.NOT_ACTIONED` were chosen, attempt to + relay the incident message to #incidents-archive + * If relay successful, delete original message + * See: `on_raw_reaction_add` + + Please refer to function docstrings for implementation details. + """ + + def __init__(self, bot: Bot) -> None: + """Prepare `event_lock` and schedule `crawl_task` on start-up.""" + self.bot = bot + + self.event_lock = asyncio.Lock() + self.crawl_task = self.bot.loop.create_task(self.crawl_incidents()) + + async def crawl_incidents(self) -> None: + """ + Crawl #incidents and add missing emoji where necessary. + + This is to catch-up should an incident be reported while the bot wasn't listening. + After adding each reaction, we take a short break to avoid drowning in ratelimits. + + Once this task is scheduled, listeners that change messages should await it. + The crawl assumes that the channel history doesn't change as we go over it. + + Behaviour is configured by: `CRAWL_LIMIT`, `CRAWL_SLEEP`. + """ + await self.bot.wait_until_guild_available() + incidents: discord.TextChannel = self.bot.get_channel(Channels.incidents) + + log.debug(f"Crawling messages in #incidents: {CRAWL_LIMIT=}, {CRAWL_SLEEP=}") + async for message in incidents.history(limit=CRAWL_LIMIT): + + if not is_incident(message): + log.trace(f"Skipping message {message.id}: not an incident") + continue + + if has_signals(message): + log.trace(f"Skipping message {message.id}: already has all signals") + continue + + await add_signals(message) + await asyncio.sleep(CRAWL_SLEEP) + + log.debug("Crawl task finished!") + + async def archive(self, incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> bool: + """ + Relay an embed representation of `incident` to the #incidents-archive channel. + + The following pieces of information are relayed: + * Incident message content (as embed description) + * Incident attachment (if image, shown in archive embed) + * Incident author name (as webhook author) + * Incident author avatar (as webhook avatar) + * Resolution signal `outcome` (as embed colour & footer) + * Moderator `actioned_by` (name & discriminator shown in footer) + + If `incident` contains an attachment, we try to add it to the archive embed. There is + no handing of extensions / file types - we simply dispatch the attachment file with the + webhook, and try to display it in the embed. Testing indicates that if the attachment + cannot be displayed (e.g. a text file), it's invisible in the embed, with no error. + + Return True if the relay finishes successfully. If anything goes wrong, meaning + not all information was relayed, return False. This signals that the original + message is not safe to be deleted, as we will lose some information. + """ + log.debug(f"Archiving incident: {incident.id} (outcome: {outcome}, actioned by: {actioned_by})") + embed, attachment_file = await make_embed(incident, outcome, actioned_by) + + try: + webhook = await self.bot.fetch_webhook(Webhooks.incidents_archive) + await webhook.send( + embed=embed, + username=sub_clyde(incident.author.name), + avatar_url=incident.author.avatar_url, + file=attachment_file, + ) + except Exception: + log.exception(f"Failed to archive incident {incident.id} to #incidents-archive") + return False + else: + log.trace("Message archived successfully!") + return True + + def make_confirmation_task(self, incident: discord.Message, timeout: int = 5) -> asyncio.Task: + """ + Create a task to wait `timeout` seconds for `incident` to be deleted. + + If `timeout` passes, this will raise `asyncio.TimeoutError`, signaling that we haven't + been able to confirm that the message was deleted. + """ + log.trace(f"Confirmation task will wait {timeout=} seconds for {incident.id=} to be deleted") + + def check(payload: discord.RawReactionActionEvent) -> bool: + return payload.message_id == incident.id + + coroutine = self.bot.wait_for(event="raw_message_delete", check=check, timeout=timeout) + return self.bot.loop.create_task(coroutine) + + async def process_event(self, reaction: str, incident: discord.Message, member: discord.Member) -> None: + """ + Process a `reaction_add` event in #incidents. + + First, we check that the reaction is a recognized `Signal` member, and that it was sent by + a permitted user (at least one role in `ALLOWED_ROLES`). If not, the reaction is removed. + + If the reaction was either `Signal.ACTIONED` or `Signal.NOT_ACTIONED`, we attempt to relay + the report to #incidents-archive. If successful, the original message is deleted. + + We do not release `event_lock` until we receive the corresponding `message_delete` event. + This ensures that if there is a racing event awaiting the lock, it will fail to find the + message, and will abort. There is a `timeout` to ensure that this doesn't hold the lock + forever should something go wrong. + """ + members_roles: t.Set[int] = {role.id for role in member.roles} + if not members_roles & ALLOWED_ROLES: # Intersection is truthy on at least 1 common element + log.debug(f"Removing invalid reaction: user {member} is not permitted to send signals") + await incident.remove_reaction(reaction, member) + return + + try: + signal = Signal(reaction) + except ValueError: + log.debug(f"Removing invalid reaction: emoji {reaction} is not a valid signal") + await incident.remove_reaction(reaction, member) + return + + log.trace(f"Received signal: {signal}") + + if signal not in (Signal.ACTIONED, Signal.NOT_ACTIONED): + log.debug("Reaction was valid, but no action is currently defined for it") + return + + relay_successful = await self.archive(incident, signal, actioned_by=member) + if not relay_successful: + log.trace("Original message will not be deleted as we failed to relay it to the archive") + return + + timeout = 5 # Seconds + confirmation_task = self.make_confirmation_task(incident, timeout) + + log.trace("Deleting original message") + await incident.delete() + + log.trace(f"Awaiting deletion confirmation: {timeout=} seconds") + try: + await confirmation_task + except asyncio.TimeoutError: + log.warning(f"Did not receive incident deletion confirmation within {timeout} seconds!") + else: + log.trace("Deletion was confirmed") + + async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]: + """ + Get `discord.Message` for `message_id` from cache, or API. + + We first look into the local cache to see if the message is present. + + If not, we try to fetch the message from the API. This is necessary for messages + which were sent before the bot's current session. + + In an edge-case, it is also possible that the message was already deleted, and + the API will respond with a 404. In such a case, None will be returned. + This signals that the event for `message_id` should be ignored. + """ + await self.bot.wait_until_guild_available() # First make sure that the cache is ready + log.trace(f"Resolving message for: {message_id=}") + message: t.Optional[discord.Message] = self.bot._connection._get_message(message_id) + + if message is not None: + log.trace("Message was found in cache") + return message + + log.trace("Message not found, attempting to fetch") + try: + message = await self.bot.get_channel(Channels.incidents).fetch_message(message_id) + except discord.NotFound: + log.trace("Message doesn't exist, it was likely already relayed") + except Exception: + log.exception(f"Failed to fetch message {message_id}!") + else: + log.trace("Message fetched successfully!") + return message + + @Cog.listener() + async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent) -> None: + """ + Pre-process `payload` and pass it to `process_event` if appropriate. + + We abort instantly if `payload` doesn't relate to a message sent in #incidents, + or if it was sent by a bot. + + If `payload` relates to a message in #incidents, we first ensure that `crawl_task` has + finished, to make sure we don't mutate channel state as we're crawling it. + + Next, we acquire `event_lock` - to prevent racing, events are processed one at a time. + + Once we have the lock, the `discord.Message` object for this event must be resolved. + If the lock was previously held by an event which successfully relayed the incident, + this will fail and we abort the current event. + + Finally, with both the lock and the `discord.Message` instance in our hands, we delegate + to `process_event` to handle the event. + + The justification for using a raw listener is the need to receive events for messages + which were not cached in the current session. As a result, a certain amount of + complexity is introduced, but at the moment this doesn't appear to be avoidable. + """ + if payload.channel_id != Channels.incidents or payload.member.bot: + return + + log.trace(f"Received reaction add event in #incidents, waiting for crawler: {self.crawl_task.done()=}") + await self.crawl_task + + log.trace(f"Acquiring event lock: {self.event_lock.locked()=}") + async with self.event_lock: + message = await self.resolve_message(payload.message_id) + + if message is None: + log.debug("Listener will abort as related message does not exist!") + return + + if not is_incident(message): + log.debug("Ignoring event for a non-incident message") + return + + await self.process_event(str(payload.emoji), message, payload.member) + log.trace("Releasing event lock") + + @Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """Pass `message` to `add_signals` if and only if it satisfies `is_incident`.""" + if is_incident(message): + await add_signals(message) + + +def setup(bot: Bot) -> None: + """Load the Incidents cog.""" + bot.add_cog(Incidents(bot)) diff --git a/bot/exts/moderation/infraction/__init__.py b/bot/exts/moderation/infraction/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py new file mode 100644 index 000000000..1310fd3d9 --- /dev/null +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -0,0 +1,463 @@ +import logging +import textwrap +import typing as t +from abc import abstractmethod +from datetime import datetime +from gettext import ngettext + +import dateutil.parser +import discord +from discord.ext.commands import Context + +from bot import constants +from bot.api import ResponseCodeError +from bot.bot import Bot +from bot.constants import Colours, STAFF_CHANNELS +from bot.exts.moderation.modlog import ModLog +from bot.utils import time +from bot.utils.scheduling import Scheduler +from . import _utils +from ._utils import UserSnowflake + +log = logging.getLogger(__name__) + + +class InfractionScheduler: + """Handles the application, pardoning, and expiration of infractions.""" + + def __init__(self, bot: Bot, supported_infractions: t.Container[str]): + self.bot = bot + self.scheduler = Scheduler(self.__class__.__name__) + + self.bot.loop.create_task(self.reschedule_infractions(supported_infractions)) + + def cog_unload(self) -> None: + """Cancel scheduled tasks.""" + self.scheduler.cancel_all() + + @property + def mod_log(self) -> ModLog: + """Get the currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + async def reschedule_infractions(self, supported_infractions: t.Container[str]) -> None: + """Schedule expiration for previous infractions.""" + await self.bot.wait_until_guild_available() + + log.trace(f"Rescheduling infractions for {self.__class__.__name__}.") + + infractions = await self.bot.api_client.get( + 'bot/infractions', + params={'active': 'true'} + ) + for infraction in infractions: + if infraction["expires_at"] is not None and infraction["type"] in supported_infractions: + self.schedule_expiration(infraction) + + async def reapply_infraction( + self, + infraction: _utils.Infraction, + apply_coro: t.Optional[t.Awaitable] + ) -> None: + """Reapply an infraction if it's still active or deactivate it if less than 60 sec left.""" + # Calculate the time remaining, in seconds, for the mute. + expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) + delta = (expiry - datetime.utcnow()).total_seconds() + + # Mark as inactive if less than a minute remains. + if delta < 60: + log.info( + "Infraction will be deactivated instead of re-applied " + "because less than 1 minute remains." + ) + await self.deactivate_infraction(infraction) + return + + # Allowing mod log since this is a passive action that should be logged. + await apply_coro + log.info(f"Re-applied {infraction['type']} to user {infraction['user']} upon rejoining.") + + async def apply_infraction( + self, + ctx: Context, + infraction: _utils.Infraction, + user: UserSnowflake, + action_coro: t.Optional[t.Awaitable] = None + ) -> None: + """Apply an infraction to the user, log the infraction, and optionally notify the user.""" + infr_type = infraction["type"] + icon = _utils.INFRACTION_ICONS[infr_type][0] + reason = infraction["reason"] + expiry = time.format_infraction_with_duration(infraction["expires_at"]) + id_ = infraction['id'] + + log.trace(f"Applying {infr_type} infraction #{id_} to {user}.") + + # Default values for the confirmation message and mod log. + confirm_msg = ":ok_hand: applied" + + # Specifying an expiry for a note or warning makes no sense. + if infr_type in ("note", "warning"): + expiry_msg = "" + else: + expiry_msg = f" until {expiry}" if expiry else " permanently" + + dm_result = "" + dm_log_text = "" + expiry_log_text = f"\nExpires: {expiry}" if expiry else "" + log_title = "applied" + log_content = None + failed = False + + # DM the user about the infraction if it's not a shadow/hidden infraction. + # This needs to happen before we apply the infraction, as the bot cannot + # send DMs to user that it doesn't share a guild with. If we were to + # apply kick/ban infractions first, this would mean that we'd make it + # impossible for us to deliver a DM. See python-discord/bot#982. + if not infraction["hidden"]: + dm_result = f"{constants.Emojis.failmail} " + dm_log_text = "\nDM: **Failed**" + + # Sometimes user is a discord.Object; make it a proper user. + try: + if not isinstance(user, (discord.Member, discord.User)): + user = await self.bot.fetch_user(user.id) + except discord.HTTPException as e: + log.error(f"Failed to DM {user.id}: could not fetch user (status {e.status})") + else: + # Accordingly display whether the user was successfully notified via DM. + if await _utils.notify_infraction(user, infr_type, expiry, reason, icon): + dm_result = ":incoming_envelope: " + dm_log_text = "\nDM: Sent" + + end_msg = "" + if infraction["actor"] == self.bot.user.id: + log.trace( + f"Infraction #{id_} actor is bot; including the reason in the confirmation message." + ) + if reason: + end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})" + elif ctx.channel.id not in STAFF_CHANNELS: + log.trace( + f"Infraction #{id_} context is not in a staff channel; omitting infraction count." + ) + else: + log.trace(f"Fetching total infraction count for {user}.") + + infractions = await self.bot.api_client.get( + "bot/infractions", + params={"user__id": str(user.id)} + ) + total = len(infractions) + end_msg = f" ({total} infraction{ngettext('', 's', total)} total)" + + # Execute the necessary actions to apply the infraction on Discord. + if action_coro: + log.trace(f"Awaiting the infraction #{id_} application action coroutine.") + try: + await action_coro + if expiry: + # Schedule the expiration of the infraction. + self.schedule_expiration(infraction) + except discord.HTTPException as e: + # Accordingly display that applying the infraction failed. + confirm_msg = ":x: failed to apply" + expiry_msg = "" + log_content = ctx.author.mention + log_title = "failed to apply" + + log_msg = f"Failed to apply {infr_type} infraction #{id_} to {user}" + if isinstance(e, discord.Forbidden): + log.warning(f"{log_msg}: bot lacks permissions.") + else: + log.exception(log_msg) + failed = True + + if failed: + log.trace(f"Deleted infraction {infraction['id']} from database because applying infraction failed.") + try: + await self.bot.api_client.delete(f"bot/infractions/{id_}") + except ResponseCodeError as e: + confirm_msg += " and failed to delete" + log_title += " and failed to delete" + log.error(f"Deletion of {infr_type} infraction #{id_} failed with error code {e.status}.") + infr_message = "" + else: + infr_message = f" **{infr_type}** to {user.mention}{expiry_msg}{end_msg}" + + # Send a confirmation message to the invoking context. + log.trace(f"Sending infraction #{id_} confirmation message.") + await ctx.send(f"{dm_result}{confirm_msg}{infr_message}.") + + # Send a log message to the mod log. + log.trace(f"Sending apply mod log for infraction #{id_}.") + await self.mod_log.send_log_message( + icon_url=icon, + colour=Colours.soft_red, + title=f"Infraction {log_title}: {infr_type}", + thumbnail=user.avatar_url_as(static_format="png"), + text=textwrap.dedent(f""" + Member: {user.mention} (`{user.id}`) + Actor: {ctx.message.author}{dm_log_text}{expiry_log_text} + Reason: {reason} + """), + content=log_content, + footer=f"ID {infraction['id']}" + ) + + log.info(f"Applied {infr_type} infraction #{id_} to {user}.") + + async def pardon_infraction( + self, + ctx: Context, + infr_type: str, + user: UserSnowflake, + send_msg: bool = True + ) -> None: + """ + Prematurely end an infraction for a user and log the action in the mod log. + + If `send_msg` is True, then a pardoning confirmation message will be sent to + the context channel. Otherwise, no such message will be sent. + """ + log.trace(f"Pardoning {infr_type} infraction for {user}.") + + # Check the current active infraction + log.trace(f"Fetching active {infr_type} infractions for {user}.") + response = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': infr_type, + 'user__id': user.id + } + ) + + if not response: + log.debug(f"No active {infr_type} infraction found for {user}.") + await ctx.send(f":x: There's no active {infr_type} infraction for user {user.mention}.") + return + + # Deactivate the infraction and cancel its scheduled expiration task. + log_text = await self.deactivate_infraction(response[0], send_log=False) + + log_text["Member"] = f"{user.mention}(`{user.id}`)" + log_text["Actor"] = str(ctx.message.author) + log_content = None + id_ = response[0]['id'] + footer = f"ID: {id_}" + + # If multiple active infractions were found, mark them as inactive in the database + # and cancel their expiration tasks. + if len(response) > 1: + log.info( + f"Found more than one active {infr_type} infraction for user {user.id}; " + "deactivating the extra active infractions too." + ) + + footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}" + + log_note = f"Found multiple **active** {infr_type} infractions in the database." + if "Note" in log_text: + log_text["Note"] = f" {log_note}" + else: + log_text["Note"] = log_note + + # deactivate_infraction() is not called again because: + # 1. Discord cannot store multiple active bans or assign multiples of the same role + # 2. It would send a pardon DM for each active infraction, which is redundant + for infraction in response[1:]: + id_ = infraction['id'] + try: + # Mark infraction as inactive in the database. + await self.bot.api_client.patch( + f"bot/infractions/{id_}", + json={"active": False} + ) + except ResponseCodeError: + log.exception(f"Failed to deactivate infraction #{id_} ({infr_type})") + # This is simpler and cleaner than trying to concatenate all the errors. + log_text["Failure"] = "See bot's logs for details." + + # Cancel pending expiration task. + if infraction["expires_at"] is not None: + self.scheduler.cancel(infraction["id"]) + + # Accordingly display whether the user was successfully notified via DM. + dm_emoji = "" + if log_text.get("DM") == "Sent": + dm_emoji = ":incoming_envelope: " + elif "DM" in log_text: + dm_emoji = f"{constants.Emojis.failmail} " + + # Accordingly display whether the pardon failed. + if "Failure" in log_text: + confirm_msg = ":x: failed to pardon" + log_title = "pardon failed" + log_content = ctx.author.mention + + log.warning(f"Failed to pardon {infr_type} infraction #{id_} for {user}.") + else: + confirm_msg = ":ok_hand: pardoned" + log_title = "pardoned" + + log.info(f"Pardoned {infr_type} infraction #{id_} for {user}.") + + # Send a confirmation message to the invoking context. + if send_msg: + log.trace(f"Sending infraction #{id_} pardon confirmation message.") + await ctx.send( + f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. " + f"{log_text.get('Failure', '')}" + ) + + # Move reason to end of entry to avoid cutting out some keys + log_text["Reason"] = log_text.pop("Reason") + + # Send a log message to the mod log. + await self.mod_log.send_log_message( + icon_url=_utils.INFRACTION_ICONS[infr_type][1], + colour=Colours.soft_green, + title=f"Infraction {log_title}: {infr_type}", + thumbnail=user.avatar_url_as(static_format="png"), + text="\n".join(f"{k}: {v}" for k, v in log_text.items()), + footer=footer, + content=log_content, + ) + + async def deactivate_infraction( + self, + infraction: _utils.Infraction, + send_log: bool = True + ) -> t.Dict[str, str]: + """ + Deactivate an active infraction and return a dictionary of lines to send in a mod log. + + The infraction is removed from Discord, marked as inactive in the database, and has its + expiration task cancelled. If `send_log` is True, a mod log is sent for the + deactivation of the infraction. + + Infractions of unsupported types will raise a ValueError. + """ + guild = self.bot.get_guild(constants.Guild.id) + mod_role = guild.get_role(constants.Roles.moderators) + user_id = infraction["user"] + actor = infraction["actor"] + type_ = infraction["type"] + id_ = infraction["id"] + inserted_at = infraction["inserted_at"] + expiry = infraction["expires_at"] + + log.info(f"Marking infraction #{id_} as inactive (expired).") + + expiry = dateutil.parser.isoparse(expiry).replace(tzinfo=None) if expiry else None + created = time.format_infraction_with_duration(inserted_at, expiry) + + log_content = None + log_text = { + "Member": f"<@{user_id}>", + "Actor": str(self.bot.get_user(actor) or actor), + "Reason": infraction["reason"], + "Created": created, + } + + try: + log.trace("Awaiting the pardon action coroutine.") + returned_log = await self._pardon_action(infraction) + + if returned_log is not None: + log_text = {**log_text, **returned_log} # Merge the logs together + else: + raise ValueError( + f"Attempted to deactivate an unsupported infraction #{id_} ({type_})!" + ) + except discord.Forbidden: + log.warning(f"Failed to deactivate infraction #{id_} ({type_}): bot lacks permissions.") + log_text["Failure"] = "The bot lacks permissions to do this (role hierarchy?)" + log_content = mod_role.mention + except discord.HTTPException as e: + log.exception(f"Failed to deactivate infraction #{id_} ({type_})") + log_text["Failure"] = f"HTTPException with status {e.status} and code {e.code}." + log_content = mod_role.mention + + # Check if the user is currently being watched by Big Brother. + try: + log.trace(f"Determining if user {user_id} is currently being watched by Big Brother.") + + active_watch = await self.bot.api_client.get( + "bot/infractions", + params={ + "active": "true", + "type": "watch", + "user__id": user_id + } + ) + + log_text["Watching"] = "Yes" if active_watch else "No" + except ResponseCodeError: + log.exception(f"Failed to fetch watch status for user {user_id}") + log_text["Watching"] = "Unknown - failed to fetch watch status." + + try: + # Mark infraction as inactive in the database. + log.trace(f"Marking infraction #{id_} as inactive in the database.") + await self.bot.api_client.patch( + f"bot/infractions/{id_}", + json={"active": False} + ) + except ResponseCodeError as e: + log.exception(f"Failed to deactivate infraction #{id_} ({type_})") + log_line = f"API request failed with code {e.status}." + log_content = mod_role.mention + + # Append to an existing failure message if possible + if "Failure" in log_text: + log_text["Failure"] += f" {log_line}" + else: + log_text["Failure"] = log_line + + # Cancel the expiration task. + if infraction["expires_at"] is not None: + self.scheduler.cancel(infraction["id"]) + + # Send a log message to the mod log. + if send_log: + log_title = "expiration failed" if "Failure" in log_text else "expired" + + user = self.bot.get_user(user_id) + avatar = user.avatar_url_as(static_format="png") if user else None + + # Move reason to end so when reason is too long, this is not gonna cut out required items. + log_text["Reason"] = log_text.pop("Reason") + + log.trace(f"Sending deactivation mod log for infraction #{id_}.") + await self.mod_log.send_log_message( + icon_url=_utils.INFRACTION_ICONS[type_][1], + colour=Colours.soft_green, + title=f"Infraction {log_title}: {type_}", + thumbnail=avatar, + text="\n".join(f"{k}: {v}" for k, v in log_text.items()), + footer=f"ID: {id_}", + content=log_content, + ) + + return log_text + + @abstractmethod + async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]: + """ + Execute deactivation steps specific to the infraction's type and return a log dict. + + If an infraction type is unsupported, return None instead. + """ + raise NotImplementedError + + def schedule_expiration(self, infraction: _utils.Infraction) -> None: + """ + Marks an infraction expired after the delay from time of scheduling to time of expiration. + + At the time of expiration, the infraction is marked as inactive on the website and the + expiration task is cancelled. + """ + expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) + self.scheduler.schedule_at(expiry, infraction["id"], self.deactivate_infraction(infraction)) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py new file mode 100644 index 000000000..fb55287b6 --- /dev/null +++ b/bot/exts/moderation/infraction/_utils.py @@ -0,0 +1,201 @@ +import logging +import textwrap +import typing as t +from datetime import datetime + +import discord +from discord.ext.commands import Context + +from bot.api import ResponseCodeError +from bot.constants import Colours, Icons + +log = logging.getLogger(__name__) + +# apply icon, pardon icon +INFRACTION_ICONS = { + "ban": (Icons.user_ban, Icons.user_unban), + "kick": (Icons.sign_out, None), + "mute": (Icons.user_mute, Icons.user_unmute), + "note": (Icons.user_warn, None), + "superstar": (Icons.superstarify, Icons.unsuperstarify), + "warning": (Icons.user_warn, None), +} +RULES_URL = "https://pythondiscord.com/pages/rules" +APPEALABLE_INFRACTIONS = ("ban", "mute") + +# Type aliases +UserObject = t.Union[discord.Member, discord.User] +UserSnowflake = t.Union[UserObject, discord.Object] +Infraction = t.Dict[str, t.Union[str, int, bool]] + + +async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]: + """ + Create a new user in the database. + + Used when an infraction needs to be applied on a user absent in the guild. + """ + log.trace(f"Attempting to add user {user.id} to the database.") + + if not isinstance(user, (discord.Member, discord.User)): + log.debug("The user being added to the DB is not a Member or User object.") + + payload = { + 'discriminator': int(getattr(user, 'discriminator', 0)), + 'id': user.id, + 'in_guild': False, + 'name': getattr(user, 'name', 'Name unknown'), + 'roles': [] + } + + try: + response = await ctx.bot.api_client.post('bot/users', json=payload) + log.info(f"User {user.id} added to the DB.") + return response + except ResponseCodeError as e: + log.error(f"Failed to add user {user.id} to the DB. {e}") + await ctx.send(f":x: The attempt to add the user to the DB failed: status {e.status}") + + +async def post_infraction( + ctx: Context, + user: UserSnowflake, + infr_type: str, + reason: str, + expires_at: datetime = None, + hidden: bool = False, + active: bool = True +) -> t.Optional[dict]: + """Posts an infraction to the API.""" + log.trace(f"Posting {infr_type} infraction for {user} to the API.") + + payload = { + "actor": ctx.message.author.id, + "hidden": hidden, + "reason": reason, + "type": infr_type, + "user": user.id, + "active": active + } + if expires_at: + payload['expires_at'] = expires_at.isoformat() + + # Try to apply the infraction. If it fails because the user doesn't exist, try to add it. + for should_post_user in (True, False): + try: + response = await ctx.bot.api_client.post('bot/infractions', json=payload) + return response + except ResponseCodeError as e: + if e.status == 400 and 'user' in e.response_json: + # Only one attempt to add the user to the database, not two: + if not should_post_user or await post_user(ctx, user) is None: + return + else: + log.exception(f"Unexpected error while adding an infraction for {user}:") + await ctx.send(f":x: There was an error adding the infraction: status {e.status}.") + return + + +async def get_active_infraction( + ctx: Context, + user: UserSnowflake, + infr_type: str, + send_msg: bool = True +) -> t.Optional[dict]: + """ + Retrieves an active infraction of the given type for the user. + + If `send_msg` is True and the user has an active infraction matching the `infr_type` parameter, + then a message for the moderator will be sent to the context channel letting them know. + Otherwise, no message will be sent. + """ + log.trace(f"Checking if {user} has active infractions of type {infr_type}.") + + active_infractions = await ctx.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': infr_type, + 'user__id': str(user.id) + } + ) + if active_infractions: + # Checks to see if the moderator should be told there is an active infraction + if send_msg: + log.trace(f"{user} has active infractions of type {infr_type}.") + await ctx.send( + f":x: According to my records, this user already has a {infr_type} infraction. " + f"See infraction **#{active_infractions[0]['id']}**." + ) + return active_infractions[0] + else: + log.trace(f"{user} does not have active infractions of type {infr_type}.") + + +async def notify_infraction( + user: UserObject, + infr_type: str, + expires_at: t.Optional[str] = None, + reason: t.Optional[str] = None, + icon_url: str = Icons.token_removed +) -> bool: + """DM a user about their new infraction and return True if the DM is successful.""" + log.trace(f"Sending {user} a DM about their {infr_type} infraction.") + + text = textwrap.dedent(f""" + **Type:** {infr_type.capitalize()} + **Expires:** {expires_at or "N/A"} + **Reason:** {reason or "No reason provided."} + """) + + embed = discord.Embed( + description=textwrap.shorten(text, width=2048, placeholder="..."), + colour=Colours.soft_red + ) + + embed.set_author(name="Infraction information", icon_url=icon_url, url=RULES_URL) + embed.title = f"Please review our rules over at {RULES_URL}" + embed.url = RULES_URL + + if infr_type in APPEALABLE_INFRACTIONS: + embed.set_footer( + text="To appeal this infraction, send an e-mail to appeals@pythondiscord.com" + ) + + return await send_private_embed(user, embed) + + +async def notify_pardon( + user: UserObject, + title: str, + content: str, + icon_url: str = Icons.user_verified +) -> bool: + """DM a user about their pardoned infraction and return True if the DM is successful.""" + log.trace(f"Sending {user} a DM about their pardoned infraction.") + + embed = discord.Embed( + description=content, + colour=Colours.soft_green + ) + + embed.set_author(name=title, icon_url=icon_url) + + return await send_private_embed(user, embed) + + +async def send_private_embed(user: UserObject, embed: discord.Embed) -> bool: + """ + A helper method for sending an embed to a user's DMs. + + Returns a boolean indicator of DM success. + """ + try: + await user.send(embed=embed) + return True + except (discord.HTTPException, discord.Forbidden, discord.NotFound): + log.debug( + f"Infraction-related information could not be sent to user {user} ({user.id}). " + "The user either could not be retrieved or probably disabled their DMs." + ) + return False diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py new file mode 100644 index 000000000..cb459b447 --- /dev/null +++ b/bot/exts/moderation/infraction/infractions.py @@ -0,0 +1,375 @@ +import logging +import textwrap +import typing as t + +import discord +from discord import Member +from discord.ext import commands +from discord.ext.commands import Context, command + +from bot import constants +from bot.bot import Bot +from bot.constants import Event +from bot.converters import Expiry, FetchedMember +from bot.decorators import respect_role_hierarchy +from bot.utils.checks import with_role_check +from . import _utils +from ._scheduler import InfractionScheduler +from ._utils import UserSnowflake + +log = logging.getLogger(__name__) + + +class Infractions(InfractionScheduler, commands.Cog): + """Apply and pardon infractions on users for moderation purposes.""" + + category = "Moderation" + category_description = "Server moderation tools." + + def __init__(self, bot: Bot): + super().__init__(bot, supported_infractions={"ban", "kick", "mute", "note", "warning"}) + + self.category = "Moderation" + self._muted_role = discord.Object(constants.Roles.muted) + + @commands.Cog.listener() + async def on_member_join(self, member: Member) -> None: + """Reapply active mute infractions for returning members.""" + active_mutes = await self.bot.api_client.get( + "bot/infractions", + params={ + "active": "true", + "type": "mute", + "user__id": member.id + } + ) + + if active_mutes: + reason = f"Re-applying active mute: {active_mutes[0]['id']}" + action = member.add_roles(self._muted_role, reason=reason) + + await self.reapply_infraction(active_mutes[0], action) + + # region: Permanent infractions + + @command() + async def warn(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: + """Warn a user for the given reason.""" + infraction = await _utils.post_infraction(ctx, user, "warning", reason, active=False) + if infraction is None: + return + + await self.apply_infraction(ctx, infraction, user) + + @command() + async def kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: + """Kick a user for the given reason.""" + await self.apply_kick(ctx, user, reason) + + @command() + async def ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: + """Permanently ban a user for the given reason and stop watching them with Big Brother.""" + await self.apply_ban(ctx, user, reason) + + # endregion + # region: Temporary infractions + + @command(aliases=["mute"]) + async def tempmute(self, ctx: Context, user: Member, duration: Expiry, *, reason: t.Optional[str] = None) -> None: + """ + Temporarily mute a user for the given reason and duration. + + 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. + """ + await self.apply_mute(ctx, user, reason, expires_at=duration) + + @command() + async def tempban( + self, + ctx: Context, + user: FetchedMember, + duration: Expiry, + *, + reason: t.Optional[str] = None + ) -> None: + """ + Temporarily ban a user for the given reason and duration. + + 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. + """ + await self.apply_ban(ctx, user, reason, expires_at=duration) + + # endregion + # region: Permanent shadow infractions + + @command(hidden=True) + async def note(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: + """Create a private note for a user with the given reason without notifying the user.""" + infraction = await _utils.post_infraction(ctx, user, "note", reason, hidden=True, active=False) + if infraction is None: + return + + await self.apply_infraction(ctx, infraction, user) + + @command(hidden=True, aliases=['shadowkick', 'skick']) + async def shadow_kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: + """Kick a user for the given reason without notifying the user.""" + await self.apply_kick(ctx, user, reason, hidden=True) + + @command(hidden=True, aliases=['shadowban', 'sban']) + async def shadow_ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: + """Permanently ban a user for the given reason without notifying the user.""" + await self.apply_ban(ctx, user, reason, hidden=True) + + # endregion + # region: Temporary shadow infractions + + @command(hidden=True, aliases=["shadowtempmute, stempmute", "shadowmute", "smute"]) + async def shadow_tempmute( + self, ctx: Context, + user: Member, + duration: Expiry, + *, + reason: t.Optional[str] = None + ) -> None: + """ + Temporarily mute a user for the given reason and duration without notifying the user. + + 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. + """ + await self.apply_mute(ctx, user, reason, expires_at=duration, hidden=True) + + @command(hidden=True, aliases=["shadowtempban, stempban"]) + async def shadow_tempban( + self, + ctx: Context, + user: FetchedMember, + duration: Expiry, + *, + reason: t.Optional[str] = None + ) -> None: + """ + Temporarily ban a user for the given reason and duration without notifying the user. + + 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. + """ + await self.apply_ban(ctx, user, reason, expires_at=duration, hidden=True) + + # endregion + # region: Remove infractions (un- commands) + + @command() + async def unmute(self, ctx: Context, user: FetchedMember) -> None: + """Prematurely end the active mute infraction for the user.""" + await self.pardon_infraction(ctx, "mute", user) + + @command() + async def unban(self, ctx: Context, user: FetchedMember) -> None: + """Prematurely end the active ban infraction for the user.""" + await self.pardon_infraction(ctx, "ban", user) + + # endregion + # region: Base apply functions + + async def apply_mute(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None: + """Apply a mute infraction with kwargs passed to `post_infraction`.""" + if await _utils.get_active_infraction(ctx, user, "mute"): + return + + infraction = await _utils.post_infraction(ctx, user, "mute", reason, active=True, **kwargs) + if infraction is None: + return + + self.mod_log.ignore(Event.member_update, user.id) + + async def action() -> None: + await user.add_roles(self._muted_role, reason=reason) + + log.trace(f"Attempting to kick {user} from voice because they've been muted.") + await user.move_to(None, reason=reason) + + await self.apply_infraction(ctx, infraction, user, action()) + + @respect_role_hierarchy() + async def apply_kick(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None: + """Apply a kick infraction with kwargs passed to `post_infraction`.""" + infraction = await _utils.post_infraction(ctx, user, "kick", reason, active=False, **kwargs) + if infraction is None: + return + + self.mod_log.ignore(Event.member_remove, user.id) + + if reason: + reason = textwrap.shorten(reason, width=512, placeholder="...") + + action = user.kick(reason=reason) + await self.apply_infraction(ctx, infraction, user, action) + + @respect_role_hierarchy() + async def apply_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None: + """ + Apply a ban infraction with kwargs passed to `post_infraction`. + + Will also remove the banned user from the Big Brother watch list if applicable. + """ + # In the case of a permanent ban, we don't need get_active_infractions to tell us if one is active + is_temporary = kwargs.get("expires_at") is not None + active_infraction = await _utils.get_active_infraction(ctx, user, "ban", is_temporary) + + if active_infraction: + if is_temporary: + log.trace("Tempban ignored as it cannot overwrite an active ban.") + return + + if active_infraction.get('expires_at') is None: + log.trace("Permaban already exists, notify.") + await ctx.send(f":x: User is already permanently banned (#{active_infraction['id']}).") + return + + log.trace("Old tempban is being replaced by new permaban.") + await self.pardon_infraction(ctx, "ban", user, is_temporary) + + infraction = await _utils.post_infraction(ctx, user, "ban", reason, active=True, **kwargs) + if infraction is None: + return + + self.mod_log.ignore(Event.member_remove, user.id) + + if reason: + reason = textwrap.shorten(reason, width=512, placeholder="...") + + action = ctx.guild.ban(user, reason=reason, delete_message_days=0) + await self.apply_infraction(ctx, infraction, user, action) + + if infraction.get('expires_at') is not None: + log.trace(f"Ban isn't permanent; user {user} won't be unwatched by Big Brother.") + return + + bb_cog = self.bot.get_cog("Big Brother") + if not bb_cog: + log.error(f"Big Brother cog not loaded; perma-banned user {user} won't be unwatched.") + return + + log.trace(f"Big Brother cog loaded; attempting to unwatch perma-banned user {user}.") + + bb_reason = "User has been permanently banned from the server. Automatically removed." + await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False) + + # endregion + # region: Base pardon functions + + async def pardon_mute(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]: + """Remove a user's muted role, DM them a notification, and return a log dict.""" + user = guild.get_member(user_id) + log_text = {} + + if user: + # Remove the muted role. + self.mod_log.ignore(Event.member_update, user.id) + await user.remove_roles(self._muted_role, reason=reason) + + # DM the user about the expiration. + notified = await _utils.notify_pardon( + user=user, + title="You have been unmuted", + content="You may now send messages in the server.", + icon_url=_utils.INFRACTION_ICONS["mute"][1] + ) + + log_text["Member"] = f"{user.mention}(`{user.id}`)" + log_text["DM"] = "Sent" if notified else "**Failed**" + else: + log.info(f"Failed to unmute user {user_id}: user not found") + log_text["Failure"] = "User was not found in the guild." + + return log_text + + async def pardon_ban(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]: + """Remove a user's ban on the Discord guild and return a log dict.""" + user = discord.Object(user_id) + log_text = {} + + self.mod_log.ignore(Event.member_unban, user_id) + + try: + await guild.unban(user, reason=reason) + except discord.NotFound: + log.info(f"Failed to unban user {user_id}: no active ban found on Discord") + log_text["Note"] = "No active ban found on Discord." + + return log_text + + async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]: + """ + Execute deactivation steps specific to the infraction's type and return a log dict. + + If an infraction type is unsupported, return None instead. + """ + guild = self.bot.get_guild(constants.Guild.id) + user_id = infraction["user"] + reason = f"Infraction #{infraction['id']} expired or was pardoned." + + if infraction["type"] == "mute": + return await self.pardon_mute(user_id, guild, reason) + elif infraction["type"] == "ban": + return await self.pardon_ban(user_id, guild, reason) + + # endregion + + # This cannot be static (must have a __func__ attribute). + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + return with_role_check(ctx, *constants.MODERATION_ROLES) + + # This cannot be static (must have a __func__ attribute). + async def cog_command_error(self, ctx: Context, error: Exception) -> None: + """Send a notification to the invoking context on a Union failure.""" + if isinstance(error, commands.BadUnionArgument): + if discord.User in error.converters or discord.Member in error.converters: + await ctx.send(str(error.errors[0])) + error.handled = True + + +def setup(bot: Bot) -> None: + """Load the Infractions cog.""" + bot.add_cog(Infractions(bot)) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py new file mode 100644 index 000000000..eea6ac9ea --- /dev/null +++ b/bot/exts/moderation/infraction/management.py @@ -0,0 +1,310 @@ +import logging +import textwrap +import typing as t +from datetime import datetime + +import discord +from discord.ext import commands +from discord.ext.commands import Context + +from bot import constants +from bot.bot import Bot +from bot.converters import Expiry, InfractionSearchQuery, allowed_strings, proxy_user +from bot.exts.moderation.modlog import ModLog +from bot.pagination import LinePaginator +from bot.utils import time +from bot.utils.checks import in_whitelist_check, with_role_check +from . import _utils +from .infractions import Infractions + +log = logging.getLogger(__name__) + + +class ModManagement(commands.Cog): + """Management of infractions.""" + + category = "Moderation" + + def __init__(self, bot: Bot): + self.bot = bot + + @property + def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + @property + def infractions_cog(self) -> Infractions: + """Get currently loaded Infractions cog instance.""" + return self.bot.get_cog("Infractions") + + # region: Edit infraction commands + + @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True) + async def infraction_group(self, ctx: Context) -> None: + """Infraction manipulation commands.""" + await ctx.send_help(ctx.command) + + @infraction_group.command(name='edit') + async def infraction_edit( + self, + ctx: Context, + infraction_id: t.Union[int, allowed_strings("l", "last", "recent")], # noqa: F821 + duration: t.Union[Expiry, allowed_strings("p", "permanent"), None], # noqa: F821 + *, + reason: str = None + ) -> None: + """ + Edit the duration and/or the reason of an infraction. + + Durations are relative to the time of updating and should be appended with a unit of time. + Units (∗case-sensitive): + \u2003`y` - years + \u2003`m` - months∗ + \u2003`w` - weeks + \u2003`d` - days + \u2003`h` - hours + \u2003`M` - minutes∗ + \u2003`s` - seconds + + Use "l", "last", or "recent" as the infraction ID to specify that the most recent infraction + authored by the command invoker should be edited. + + Use "p" or "permanent" to mark the infraction as permanent. Alternatively, an ISO 8601 + timestamp can be provided for the duration. + """ + if duration is None and reason is None: + # Unlike UserInputError, the error handler will show a specified message for BadArgument + raise commands.BadArgument("Neither a new expiry nor a new reason was specified.") + + # Retrieve the previous infraction for its information. + if isinstance(infraction_id, str): + params = { + "actor__id": ctx.author.id, + "ordering": "-inserted_at" + } + infractions = await self.bot.api_client.get("bot/infractions", params=params) + + if infractions: + old_infraction = infractions[0] + infraction_id = old_infraction["id"] + else: + await ctx.send( + ":x: Couldn't find most recent infraction; you have never given an infraction." + ) + return + else: + old_infraction = await self.bot.api_client.get(f"bot/infractions/{infraction_id}") + + request_data = {} + confirm_messages = [] + log_text = "" + + if duration is not None and not old_infraction['active']: + if reason is None: + await ctx.send(":x: Cannot edit the expiration of an expired infraction.") + return + confirm_messages.append("expiry unchanged (infraction already expired)") + elif isinstance(duration, str): + request_data['expires_at'] = None + confirm_messages.append("marked as permanent") + elif duration is not None: + request_data['expires_at'] = duration.isoformat() + expiry = time.format_infraction_with_duration(request_data['expires_at']) + confirm_messages.append(f"set to expire on {expiry}") + else: + confirm_messages.append("expiry unchanged") + + if reason: + request_data['reason'] = reason + confirm_messages.append("set a new reason") + log_text += f""" + Previous reason: {old_infraction['reason']} + New reason: {reason} + """.rstrip() + else: + confirm_messages.append("reason unchanged") + + # Update the infraction + new_infraction = await self.bot.api_client.patch( + f'bot/infractions/{infraction_id}', + json=request_data, + ) + + # Re-schedule infraction if the expiration has been updated + if 'expires_at' in request_data: + # A scheduled task should only exist if the old infraction wasn't permanent + if old_infraction['expires_at']: + self.infractions_cog.scheduler.cancel(new_infraction['id']) + + # If the infraction was not marked as permanent, schedule a new expiration task + if request_data['expires_at']: + self.infractions_cog.schedule_expiration(new_infraction) + + log_text += f""" + Previous expiry: {old_infraction['expires_at'] or "Permanent"} + New expiry: {new_infraction['expires_at'] or "Permanent"} + """.rstrip() + + changes = ' & '.join(confirm_messages) + await ctx.send(f":ok_hand: Updated infraction #{infraction_id}: {changes}") + + # Get information about the infraction's user + user_id = new_infraction['user'] + user = ctx.guild.get_member(user_id) + + if user: + user_text = f"{user.mention} (`{user.id}`)" + thumbnail = user.avatar_url_as(static_format="png") + else: + user_text = f"`{user_id}`" + thumbnail = None + + # The infraction's actor + actor_id = new_infraction['actor'] + actor = ctx.guild.get_member(actor_id) or f"`{actor_id}`" + + await self.mod_log.send_log_message( + icon_url=constants.Icons.pencil, + colour=discord.Colour.blurple(), + title="Infraction edited", + thumbnail=thumbnail, + text=textwrap.dedent(f""" + Member: {user_text} + Actor: {actor} + Edited by: {ctx.message.author}{log_text} + """) + ) + + # endregion + # region: Search infractions + + @infraction_group.group(name="search", invoke_without_command=True) + async def infraction_search_group(self, ctx: Context, query: InfractionSearchQuery) -> None: + """Searches for infractions in the database.""" + if isinstance(query, discord.User): + await ctx.invoke(self.search_user, query) + else: + await ctx.invoke(self.search_reason, query) + + @infraction_search_group.command(name="user", aliases=("member", "id")) + async def search_user(self, ctx: Context, user: t.Union[discord.User, proxy_user]) -> None: + """Search for infractions by member.""" + infraction_list = await self.bot.api_client.get( + 'bot/infractions', + params={'user__id': str(user.id)} + ) + embed = discord.Embed( + title=f"Infractions for {user} ({len(infraction_list)} total)", + colour=discord.Colour.orange() + ) + await self.send_infraction_list(ctx, embed, infraction_list) + + @infraction_search_group.command(name="reason", aliases=("match", "regex", "re")) + async def search_reason(self, ctx: Context, reason: str) -> None: + """Search for infractions by their reason. Use Re2 for matching.""" + infraction_list = await self.bot.api_client.get( + 'bot/infractions', + params={'search': reason} + ) + embed = discord.Embed( + title=f"Infractions matching `{reason}` ({len(infraction_list)} total)", + colour=discord.Colour.orange() + ) + await self.send_infraction_list(ctx, embed, infraction_list) + + # endregion + # region: Utility functions + + async def send_infraction_list( + self, + ctx: Context, + embed: discord.Embed, + infractions: t.Iterable[_utils.Infraction] + ) -> None: + """Send a paginated embed of infractions for the specified user.""" + if not infractions: + await ctx.send(":warning: No infractions could be found for that query.") + return + + lines = tuple( + self.infraction_to_string(infraction) + for infraction in infractions + ) + + await LinePaginator.paginate( + lines, + ctx=ctx, + embed=embed, + empty=True, + max_lines=3, + max_size=1000 + ) + + def infraction_to_string(self, infraction: _utils.Infraction) -> str: + """Convert the infraction object to a string representation.""" + actor_id = infraction["actor"] + guild = self.bot.get_guild(constants.Guild.id) + actor = guild.get_member(actor_id) + active = infraction["active"] + user_id = infraction["user"] + hidden = infraction["hidden"] + created = time.format_infraction(infraction["inserted_at"]) + + if active: + remaining = time.until_expiration(infraction["expires_at"]) or "Expired" + else: + remaining = "Inactive" + + if infraction["expires_at"] is None: + expires = "*Permanent*" + else: + date_from = datetime.strptime(created, time.INFRACTION_FORMAT) + expires = time.format_infraction_with_duration(infraction["expires_at"], date_from) + + lines = textwrap.dedent(f""" + {"**===============**" if active else "==============="} + Status: {"__**Active**__" if active else "Inactive"} + User: {self.bot.get_user(user_id)} (`{user_id}`) + Type: **{infraction["type"]}** + Shadow: {hidden} + Created: {created} + Expires: {expires} + Remaining: {remaining} + Actor: {actor.mention if actor else actor_id} + ID: `{infraction["id"]}` + Reason: {infraction["reason"] or "*None*"} + {"**===============**" if active else "==============="} + """) + + return lines.strip() + + # endregion + + # This cannot be static (must have a __func__ attribute). + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators inside moderator channels to invoke the commands in this cog.""" + checks = [ + with_role_check(ctx, *constants.MODERATION_ROLES), + in_whitelist_check( + ctx, + channels=constants.MODERATION_CHANNELS, + categories=[constants.Categories.modmail], + redirect=None, + fail_silently=True, + ) + ] + return all(checks) + + # This cannot be static (must have a __func__ attribute). + async def cog_command_error(self, ctx: Context, error: Exception) -> None: + """Send a notification to the invoking context on a Union failure.""" + if isinstance(error, commands.BadUnionArgument): + if discord.User in error.converters: + await ctx.send(str(error.errors[0])) + error.handled = True + + +def setup(bot: Bot) -> None: + """Load the ModManagement cog.""" + bot.add_cog(ModManagement(bot)) diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py new file mode 100644 index 000000000..7dc5b4691 --- /dev/null +++ b/bot/exts/moderation/infraction/superstarify.py @@ -0,0 +1,244 @@ +import json +import logging +import random +import textwrap +import typing as t +from pathlib import Path + +from discord import Colour, Embed, Member +from discord.ext.commands import Cog, Context, command + +from bot import constants +from bot.bot import Bot +from bot.converters import Expiry +from bot.utils.checks import with_role_check +from bot.utils.time import format_infraction +from . import _utils +from ._scheduler import InfractionScheduler + +log = logging.getLogger(__name__) +NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#nickname-policy" + +with Path("bot/resources/stars.json").open(encoding="utf-8") as stars_file: + STAR_NAMES = json.load(stars_file) + + +class Superstarify(InfractionScheduler, Cog): + """A set of commands to moderate terrible nicknames.""" + + def __init__(self, bot: Bot): + super().__init__(bot, supported_infractions={"superstar"}) + + @Cog.listener() + async def on_member_update(self, before: Member, after: Member) -> None: + """Revert nickname edits if the user has an active superstarify infraction.""" + if before.display_name == after.display_name: + return # User didn't change their nickname. Abort! + + log.trace( + f"{before} ({before.display_name}) is trying to change their nickname to " + f"{after.display_name}. Checking if the user is in superstar-prison..." + ) + + active_superstarifies = await self.bot.api_client.get( + "bot/infractions", + params={ + "active": "true", + "type": "superstar", + "user__id": str(before.id) + } + ) + + if not active_superstarifies: + log.trace(f"{before} has no active superstar infractions.") + return + + infraction = active_superstarifies[0] + forced_nick = self.get_nick(infraction["id"], before.id) + if after.display_name == forced_nick: + return # Nick change was triggered by this event. Ignore. + + log.info( + f"{after.display_name} ({after.id}) tried to escape superstar prison. " + f"Changing the nick back to {before.display_name}." + ) + await after.edit( + nick=forced_nick, + reason=f"Superstarified member tried to escape the prison: {infraction['id']}" + ) + + notified = await _utils.notify_infraction( + user=after, + infr_type="Superstarify", + expires_at=format_infraction(infraction["expires_at"]), + reason=( + "You have tried to change your nickname on the **Python Discord** server " + f"from **{before.display_name}** to **{after.display_name}**, but as you " + "are currently in superstar-prison, you do not have permission to do so." + ), + icon_url=_utils.INFRACTION_ICONS["superstar"][0] + ) + + if not notified: + log.info("Failed to DM user about why they cannot change their nickname.") + + @Cog.listener() + async def on_member_join(self, member: Member) -> None: + """Reapply active superstar infractions for returning members.""" + active_superstarifies = await self.bot.api_client.get( + "bot/infractions", + params={ + "active": "true", + "type": "superstar", + "user__id": member.id + } + ) + + if active_superstarifies: + infraction = active_superstarifies[0] + action = member.edit( + nick=self.get_nick(infraction["id"], member.id), + reason=f"Superstarified member tried to escape the prison: {infraction['id']}" + ) + + await self.reapply_infraction(infraction, action) + + @command(name="superstarify", aliases=("force_nick", "star")) + async def superstarify( + self, + ctx: Context, + member: Member, + duration: Expiry, + *, + reason: str = None, + ) -> None: + """ + Temporarily force a random superstar name (like Taylor Swift) to be the user's nickname. + + 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. + + An optional reason can be provided. If no reason is given, the original name will be shown + in a generated reason. + """ + if await _utils.get_active_infraction(ctx, member, "superstar"): + return + + # Post the infraction to the API + reason = reason or f"old nick: {member.display_name}" + infraction = await _utils.post_infraction(ctx, member, "superstar", reason, duration, active=True) + id_ = infraction["id"] + + old_nick = member.display_name + forced_nick = self.get_nick(id_, member.id) + expiry_str = format_infraction(infraction["expires_at"]) + + # Apply the infraction and schedule the expiration task. + log.debug(f"Changing nickname of {member} to {forced_nick}.") + self.mod_log.ignore(constants.Event.member_update, member.id) + await member.edit(nick=forced_nick, reason=reason) + self.schedule_expiration(infraction) + + # Send a DM to the user to notify them of their new infraction. + await _utils.notify_infraction( + user=member, + infr_type="Superstarify", + expires_at=expiry_str, + icon_url=_utils.INFRACTION_ICONS["superstar"][0], + reason=f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL})." + ) + + # Send an embed with the infraction information to the invoking context. + log.trace(f"Sending superstar #{id_} embed.") + embed = Embed( + title="Congratulations!", + colour=constants.Colours.soft_orange, + description=( + f"Your previous nickname, **{old_nick}**, " + f"was so bad that we have decided to change it. " + f"Your new nickname will be **{forced_nick}**.\n\n" + f"You will be unable to change your nickname until **{expiry_str}**.\n\n" + "If you're confused by this, please read our " + f"[official nickname policy]({NICKNAME_POLICY_URL})." + ) + ) + await ctx.send(embed=embed) + + # Log to the mod log channel. + log.trace(f"Sending apply mod log for superstar #{id_}.") + await self.mod_log.send_log_message( + icon_url=_utils.INFRACTION_ICONS["superstar"][0], + colour=Colour.gold(), + title="Member achieved superstardom", + thumbnail=member.avatar_url_as(static_format="png"), + text=textwrap.dedent(f""" + Member: {member.mention} (`{member.id}`) + Actor: {ctx.message.author} + Expires: {expiry_str} + Old nickname: `{old_nick}` + New nickname: `{forced_nick}` + Reason: {reason} + """), + footer=f"ID {id_}" + ) + + @command(name="unsuperstarify", aliases=("release_nick", "unstar")) + async def unsuperstarify(self, ctx: Context, member: Member) -> None: + """Remove the superstarify infraction and allow the user to change their nickname.""" + await self.pardon_infraction(ctx, "superstar", member) + + async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]: + """Pardon a superstar infraction and return a log dict.""" + if infraction["type"] != "superstar": + return + + guild = self.bot.get_guild(constants.Guild.id) + user = guild.get_member(infraction["user"]) + + # Don't bother sending a notification if the user left the guild. + if not user: + log.debug( + "User left the guild and therefore won't be notified about superstar " + f"{infraction['id']} pardon." + ) + return {} + + # DM the user about the expiration. + notified = await _utils.notify_pardon( + user=user, + title="You are no longer superstarified", + content="You may now change your nickname on the server.", + icon_url=_utils.INFRACTION_ICONS["superstar"][1] + ) + + return { + "Member": f"{user.mention}(`{user.id}`)", + "DM": "Sent" if notified else "**Failed**" + } + + @staticmethod + def get_nick(infraction_id: int, member_id: int) -> str: + """Randomly select a nickname from the Superstarify nickname list.""" + log.trace(f"Choosing a random nickname for superstar #{infraction_id}.") + + rng = random.Random(str(infraction_id) + str(member_id)) + return rng.choice(STAR_NAMES) + + # This cannot be static (must have a __func__ attribute). + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + return with_role_check(ctx, *constants.MODERATION_ROLES) + + +def setup(bot: Bot) -> None: + """Load the Superstarify cog.""" + bot.add_cog(Superstarify(bot)) diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py new file mode 100644 index 000000000..c86f04b9d --- /dev/null +++ b/bot/exts/moderation/modlog.py @@ -0,0 +1,837 @@ +import asyncio +import difflib +import itertools +import logging +import typing as t +from datetime import datetime +from itertools import zip_longest + +import discord +from dateutil.relativedelta import relativedelta +from deepdiff import DeepDiff +from discord import Colour +from discord.abc import GuildChannel +from discord.ext.commands import Cog, Context +from discord.utils import escape_markdown + +from bot.bot import Bot +from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs +from bot.utils.time import humanize_delta + +log = logging.getLogger(__name__) + +GUILD_CHANNEL = t.Union[discord.CategoryChannel, discord.TextChannel, discord.VoiceChannel] + +CHANNEL_CHANGES_UNSUPPORTED = ("permissions",) +CHANNEL_CHANGES_SUPPRESSED = ("_overwrites", "position") +ROLE_CHANGES_UNSUPPORTED = ("colour", "permissions") + +VOICE_STATE_ATTRIBUTES = { + "channel.name": "Channel", + "self_stream": "Streaming", + "self_video": "Broadcasting", +} + + +class ModLog(Cog, name="ModLog"): + """Logging for server events and staff actions.""" + + def __init__(self, bot: Bot): + self.bot = bot + self._ignored = {event: [] for event in Event} + + self._cached_deletes = [] + self._cached_edits = [] + + async def upload_log( + self, + messages: t.Iterable[discord.Message], + actor_id: int, + attachments: t.Iterable[t.List[str]] = None + ) -> str: + """Upload message logs to the database and return a URL to a page for viewing the logs.""" + if attachments is None: + attachments = [] + + response = await self.bot.api_client.post( + 'bot/deleted-messages', + json={ + 'actor': actor_id, + 'creation': datetime.utcnow().isoformat(), + 'deletedmessage_set': [ + { + 'id': message.id, + 'author': message.author.id, + 'channel_id': message.channel.id, + 'content': message.content, + 'embeds': [embed.to_dict() for embed in message.embeds], + 'attachments': attachment, + } + for message, attachment in zip_longest(messages, attachments, fillvalue=[]) + ] + } + ) + + return f"{URLs.site_logs_view}/{response['id']}" + + def ignore(self, event: Event, *items: int) -> None: + """Add event to ignored events to suppress log emission.""" + for item in items: + if item not in self._ignored[event]: + self._ignored[event].append(item) + + async def send_log_message( + self, + icon_url: t.Optional[str], + colour: t.Union[discord.Colour, int], + title: t.Optional[str], + text: str, + thumbnail: t.Optional[t.Union[str, discord.Asset]] = None, + channel_id: int = Channels.mod_log, + ping_everyone: bool = False, + files: t.Optional[t.List[discord.File]] = None, + content: t.Optional[str] = None, + additional_embeds: t.Optional[t.List[discord.Embed]] = None, + additional_embeds_msg: t.Optional[str] = None, + timestamp_override: t.Optional[datetime] = None, + footer: t.Optional[str] = None, + ) -> Context: + """Generate log embed and send to logging channel.""" + # Truncate string directly here to avoid removing newlines + embed = discord.Embed( + description=text[:2045] + "..." if len(text) > 2048 else text + ) + + if title and icon_url: + embed.set_author(name=title, icon_url=icon_url) + + embed.colour = colour + embed.timestamp = timestamp_override or datetime.utcnow() + + if footer: + embed.set_footer(text=footer) + + if thumbnail: + embed.set_thumbnail(url=thumbnail) + + if ping_everyone: + if content: + content = f"@everyone\n{content}" + else: + content = "@everyone" + + channel = self.bot.get_channel(channel_id) + log_message = await channel.send( + content=content, + embed=embed, + files=files, + allowed_mentions=discord.AllowedMentions(everyone=True) + ) + + if additional_embeds: + if additional_embeds_msg: + await channel.send(additional_embeds_msg) + for additional_embed in additional_embeds: + await channel.send(embed=additional_embed) + + return await self.bot.get_context(log_message) # Optionally return for use with antispam + + @Cog.listener() + async def on_guild_channel_create(self, channel: GUILD_CHANNEL) -> None: + """Log channel create event to mod log.""" + if channel.guild.id != GuildConstant.id: + return + + if isinstance(channel, discord.CategoryChannel): + title = "Category created" + message = f"{channel.name} (`{channel.id}`)" + elif isinstance(channel, discord.VoiceChannel): + title = "Voice channel created" + + if channel.category: + message = f"{channel.category}/{channel.name} (`{channel.id}`)" + else: + message = f"{channel.name} (`{channel.id}`)" + else: + title = "Text channel created" + + if channel.category: + message = f"{channel.category}/{channel.name} (`{channel.id}`)" + else: + message = f"{channel.name} (`{channel.id}`)" + + await self.send_log_message(Icons.hash_green, Colours.soft_green, title, message) + + @Cog.listener() + async def on_guild_channel_delete(self, channel: GUILD_CHANNEL) -> None: + """Log channel delete event to mod log.""" + if channel.guild.id != GuildConstant.id: + return + + if isinstance(channel, discord.CategoryChannel): + title = "Category deleted" + elif isinstance(channel, discord.VoiceChannel): + title = "Voice channel deleted" + else: + title = "Text channel deleted" + + if channel.category and not isinstance(channel, discord.CategoryChannel): + message = f"{channel.category}/{channel.name} (`{channel.id}`)" + else: + message = f"{channel.name} (`{channel.id}`)" + + await self.send_log_message( + Icons.hash_red, Colours.soft_red, + title, message + ) + + @Cog.listener() + async def on_guild_channel_update(self, before: GUILD_CHANNEL, after: GuildChannel) -> None: + """Log channel update event to mod log.""" + if before.guild.id != GuildConstant.id: + return + + if before.id in self._ignored[Event.guild_channel_update]: + self._ignored[Event.guild_channel_update].remove(before.id) + return + + # Two channel updates are sent for a single edit: 1 for topic and 1 for category change. + # TODO: remove once support is added for ignoring multiple occurrences for the same channel. + help_categories = (Categories.help_available, Categories.help_dormant, Categories.help_in_use) + if after.category and after.category.id in help_categories: + return + + diff = DeepDiff(before, after) + changes = [] + done = [] + + diff_values = diff.get("values_changed", {}) + diff_values.update(diff.get("type_changes", {})) + + for key, value in diff_values.items(): + if not key: # Not sure why, but it happens + continue + + key = key[5:] # Remove "root." prefix + + if "[" in key: + key = key.split("[", 1)[0] + + if "." in key: + key = key.split(".", 1)[0] + + if key in done or key in CHANNEL_CHANGES_SUPPRESSED: + continue + + if key in CHANNEL_CHANGES_UNSUPPORTED: + changes.append(f"**{key.title()}** updated") + else: + new = value["new_value"] + old = value["old_value"] + + # Discord does not treat consecutive backticks ("``") as an empty inline code block, so the markdown + # formatting is broken when `new` and/or `old` are empty values. "None" is used for these cases so + # formatting is preserved. + changes.append(f"**{key.title()}:** `{old or 'None'}` **→** `{new or 'None'}`") + + done.append(key) + + if not changes: + return + + message = "" + + for item in sorted(changes): + message += f"{Emojis.bullet} {item}\n" + + if after.category: + message = f"**{after.category}/#{after.name} (`{after.id}`)**\n{message}" + else: + message = f"**#{after.name}** (`{after.id}`)\n{message}" + + await self.send_log_message( + Icons.hash_blurple, Colour.blurple(), + "Channel updated", message + ) + + @Cog.listener() + async def on_guild_role_create(self, role: discord.Role) -> None: + """Log role create event to mod log.""" + if role.guild.id != GuildConstant.id: + return + + await self.send_log_message( + Icons.crown_green, Colours.soft_green, + "Role created", f"`{role.id}`" + ) + + @Cog.listener() + async def on_guild_role_delete(self, role: discord.Role) -> None: + """Log role delete event to mod log.""" + if role.guild.id != GuildConstant.id: + return + + await self.send_log_message( + Icons.crown_red, Colours.soft_red, + "Role removed", f"{role.name} (`{role.id}`)" + ) + + @Cog.listener() + async def on_guild_role_update(self, before: discord.Role, after: discord.Role) -> None: + """Log role update event to mod log.""" + if before.guild.id != GuildConstant.id: + return + + diff = DeepDiff(before, after) + changes = [] + done = [] + + diff_values = diff.get("values_changed", {}) + diff_values.update(diff.get("type_changes", {})) + + for key, value in diff_values.items(): + if not key: # Not sure why, but it happens + continue + + key = key[5:] # Remove "root." prefix + + if "[" in key: + key = key.split("[", 1)[0] + + if "." in key: + key = key.split(".", 1)[0] + + if key in done or key == "color": + continue + + if key in ROLE_CHANGES_UNSUPPORTED: + changes.append(f"**{key.title()}** updated") + else: + new = value["new_value"] + old = value["old_value"] + + changes.append(f"**{key.title()}:** `{old}` **→** `{new}`") + + done.append(key) + + if not changes: + return + + message = "" + + for item in sorted(changes): + message += f"{Emojis.bullet} {item}\n" + + message = f"**{after.name}** (`{after.id}`)\n{message}" + + await self.send_log_message( + Icons.crown_blurple, Colour.blurple(), + "Role updated", message + ) + + @Cog.listener() + async def on_guild_update(self, before: discord.Guild, after: discord.Guild) -> None: + """Log guild update event to mod log.""" + if before.id != GuildConstant.id: + return + + diff = DeepDiff(before, after) + changes = [] + done = [] + + diff_values = diff.get("values_changed", {}) + diff_values.update(diff.get("type_changes", {})) + + for key, value in diff_values.items(): + if not key: # Not sure why, but it happens + continue + + key = key[5:] # Remove "root." prefix + + if "[" in key: + key = key.split("[", 1)[0] + + if "." in key: + key = key.split(".", 1)[0] + + if key in done: + continue + + new = value["new_value"] + old = value["old_value"] + + changes.append(f"**{key.title()}:** `{old}` **→** `{new}`") + + done.append(key) + + if not changes: + return + + message = "" + + for item in sorted(changes): + message += f"{Emojis.bullet} {item}\n" + + message = f"**{after.name}** (`{after.id}`)\n{message}" + + await self.send_log_message( + Icons.guild_update, Colour.blurple(), + "Guild updated", message, + thumbnail=after.icon_url_as(format="png") + ) + + @Cog.listener() + async def on_member_ban(self, guild: discord.Guild, member: discord.Member) -> None: + """Log ban event to user log.""" + if guild.id != GuildConstant.id: + return + + if member.id in self._ignored[Event.member_ban]: + self._ignored[Event.member_ban].remove(member.id) + return + + await self.send_log_message( + Icons.user_ban, Colours.soft_red, + "User banned", f"{member} (`{member.id}`)", + thumbnail=member.avatar_url_as(static_format="png"), + channel_id=Channels.user_log + ) + + @Cog.listener() + async def on_member_join(self, member: discord.Member) -> None: + """Log member join event to user log.""" + if member.guild.id != GuildConstant.id: + return + + member_str = escape_markdown(str(member)) + message = f"{member_str} (`{member.id}`)" + now = datetime.utcnow() + difference = abs(relativedelta(now, member.created_at)) + + message += "\n\n**Account age:** " + humanize_delta(difference) + + if difference.days < 1 and difference.months < 1 and difference.years < 1: # New user account! + message = f"{Emojis.new} {message}" + + await self.send_log_message( + Icons.sign_in, Colours.soft_green, + "User joined", message, + thumbnail=member.avatar_url_as(static_format="png"), + channel_id=Channels.user_log + ) + + @Cog.listener() + async def on_member_remove(self, member: discord.Member) -> None: + """Log member leave event to user log.""" + if member.guild.id != GuildConstant.id: + return + + if member.id in self._ignored[Event.member_remove]: + self._ignored[Event.member_remove].remove(member.id) + return + + member_str = escape_markdown(str(member)) + await self.send_log_message( + Icons.sign_out, Colours.soft_red, + "User left", f"{member_str} (`{member.id}`)", + thumbnail=member.avatar_url_as(static_format="png"), + channel_id=Channels.user_log + ) + + @Cog.listener() + async def on_member_unban(self, guild: discord.Guild, member: discord.User) -> None: + """Log member unban event to mod log.""" + if guild.id != GuildConstant.id: + return + + if member.id in self._ignored[Event.member_unban]: + self._ignored[Event.member_unban].remove(member.id) + return + + member_str = escape_markdown(str(member)) + await self.send_log_message( + Icons.user_unban, Colour.blurple(), + "User unbanned", f"{member_str} (`{member.id}`)", + thumbnail=member.avatar_url_as(static_format="png"), + channel_id=Channels.mod_log + ) + + @staticmethod + def get_role_diff(before: t.List[discord.Role], after: t.List[discord.Role]) -> t.List[str]: + """Return a list of strings describing the roles added and removed.""" + changes = [] + before_roles = set(before) + after_roles = set(after) + + for role in (before_roles - after_roles): + changes.append(f"**Role removed:** {role.name} (`{role.id}`)") + + for role in (after_roles - before_roles): + changes.append(f"**Role added:** {role.name} (`{role.id}`)") + + return changes + + @Cog.listener() + async def on_member_update(self, before: discord.Member, after: discord.Member) -> None: + """Log member update event to user log.""" + if before.guild.id != GuildConstant.id: + return + + if before.id in self._ignored[Event.member_update]: + self._ignored[Event.member_update].remove(before.id) + return + + changes = self.get_role_diff(before.roles, after.roles) + + # The regex is a simple way to exclude all sequence and mapping types. + diff = DeepDiff(before, after, exclude_regex_paths=r".*\[.*") + + # A type change seems to always take precedent over a value change. Furthermore, it will + # include the value change along with the type change anyway. Therefore, it's OK to + # "overwrite" values_changed; in practice there will never even be anything to overwrite. + diff_values = {**diff.get("values_changed", {}), **diff.get("type_changes", {})} + + for attr, value in diff_values.items(): + if not attr: # Not sure why, but it happens. + continue + + attr = attr[5:] # Remove "root." prefix. + attr = attr.replace("_", " ").replace(".", " ").capitalize() + + new = value.get("new_value") + old = value.get("old_value") + + changes.append(f"**{attr}:** `{old}` **→** `{new}`") + + if not changes: + return + + message = "" + + for item in sorted(changes): + message += f"{Emojis.bullet} {item}\n" + + member_str = escape_markdown(str(after)) + message = f"**{member_str}** (`{after.id}`)\n{message}" + + await self.send_log_message( + icon_url=Icons.user_update, + colour=Colour.blurple(), + title="Member updated", + text=message, + thumbnail=after.avatar_url_as(static_format="png"), + channel_id=Channels.user_log + ) + + @Cog.listener() + async def on_message_delete(self, message: discord.Message) -> None: + """Log message delete event to message change log.""" + channel = message.channel + author = message.author + + # Ignore DMs. + if not message.guild: + return + + if message.guild.id != GuildConstant.id or channel.id in GuildConstant.modlog_blacklist: + return + + self._cached_deletes.append(message.id) + + if message.id in self._ignored[Event.message_delete]: + self._ignored[Event.message_delete].remove(message.id) + return + + if author.bot: + return + + author_str = escape_markdown(str(author)) + if channel.category: + response = ( + f"**Author:** {author_str} (`{author.id}`)\n" + f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" + f"**Message ID:** `{message.id}`\n" + "\n" + ) + else: + response = ( + f"**Author:** {author_str} (`{author.id}`)\n" + f"**Channel:** #{channel.name} (`{channel.id}`)\n" + f"**Message ID:** `{message.id}`\n" + "\n" + ) + + if message.attachments: + # Prepend the message metadata with the number of attachments + response = f"**Attachments:** {len(message.attachments)}\n" + response + + # Shorten the message content if necessary + content = message.clean_content + remaining_chars = 2040 - len(response) + + if len(content) > remaining_chars: + botlog_url = await self.upload_log(messages=[message], actor_id=message.author.id) + ending = f"\n\nMessage truncated, [full message here]({botlog_url})." + truncation_point = remaining_chars - len(ending) + content = f"{content[:truncation_point]}...{ending}" + + response += f"{content}" + + await self.send_log_message( + Icons.message_delete, Colours.soft_red, + "Message deleted", + response, + channel_id=Channels.message_log + ) + + @Cog.listener() + async def on_raw_message_delete(self, event: discord.RawMessageDeleteEvent) -> None: + """Log raw message delete event to message change log.""" + if event.guild_id != GuildConstant.id or event.channel_id in GuildConstant.modlog_blacklist: + return + + await asyncio.sleep(1) # Wait here in case the normal event was fired + + if event.message_id in self._cached_deletes: + # It was in the cache and the normal event was fired, so we can just ignore it + self._cached_deletes.remove(event.message_id) + return + + if event.message_id in self._ignored[Event.message_delete]: + self._ignored[Event.message_delete].remove(event.message_id) + return + + channel = self.bot.get_channel(event.channel_id) + + if channel.category: + response = ( + f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" + f"**Message ID:** `{event.message_id}`\n" + "\n" + "This message was not cached, so the message content cannot be displayed." + ) + else: + response = ( + f"**Channel:** #{channel.name} (`{channel.id}`)\n" + f"**Message ID:** `{event.message_id}`\n" + "\n" + "This message was not cached, so the message content cannot be displayed." + ) + + await self.send_log_message( + Icons.message_delete, Colours.soft_red, + "Message deleted", + response, + channel_id=Channels.message_log + ) + + @Cog.listener() + async def on_message_edit(self, msg_before: discord.Message, msg_after: discord.Message) -> None: + """Log message edit event to message change log.""" + if ( + not msg_before.guild + or msg_before.guild.id != GuildConstant.id + or msg_before.channel.id in GuildConstant.modlog_blacklist + or msg_before.author.bot + ): + return + + self._cached_edits.append(msg_before.id) + + if msg_before.content == msg_after.content: + return + + author = msg_before.author + author_str = escape_markdown(str(author)) + + channel = msg_before.channel + channel_name = f"{channel.category}/#{channel.name}" if channel.category else f"#{channel.name}" + + # Getting the difference per words and group them by type - add, remove, same + # Note that this is intended grouping without sorting + diff = difflib.ndiff(msg_before.clean_content.split(), msg_after.clean_content.split()) + diff_groups = tuple( + (diff_type, tuple(s[2:] for s in diff_words)) + for diff_type, diff_words in itertools.groupby(diff, key=lambda s: s[0]) + ) + + content_before: t.List[str] = [] + content_after: t.List[str] = [] + + for index, (diff_type, words) in enumerate(diff_groups): + sub = ' '.join(words) + if diff_type == '-': + content_before.append(f"[{sub}](http://o.hi)") + elif diff_type == '+': + content_after.append(f"[{sub}](http://o.hi)") + elif diff_type == ' ': + if len(words) > 2: + sub = ( + f"{words[0] if index > 0 else ''}" + " ... " + f"{words[-1] if index < len(diff_groups) - 1 else ''}" + ) + content_before.append(sub) + content_after.append(sub) + + response = ( + f"**Author:** {author_str} (`{author.id}`)\n" + f"**Channel:** {channel_name} (`{channel.id}`)\n" + f"**Message ID:** `{msg_before.id}`\n" + "\n" + f"**Before**:\n{' '.join(content_before)}\n" + f"**After**:\n{' '.join(content_after)}\n" + "\n" + f"[Jump to message]({msg_after.jump_url})" + ) + + if msg_before.edited_at: + # Message was previously edited, to assist with self-bot detection, use the edited_at + # datetime as the baseline and create a human-readable delta between this edit event + # and the last time the message was edited + timestamp = msg_before.edited_at + delta = humanize_delta(relativedelta(msg_after.edited_at, msg_before.edited_at)) + footer = f"Last edited {delta} ago" + else: + # Message was not previously edited, use the created_at datetime as the baseline, no + # delta calculation needed + timestamp = msg_before.created_at + footer = None + + await self.send_log_message( + Icons.message_edit, Colour.blurple(), "Message edited", response, + channel_id=Channels.message_log, timestamp_override=timestamp, footer=footer + ) + + @Cog.listener() + async def on_raw_message_edit(self, event: discord.RawMessageUpdateEvent) -> None: + """Log raw message edit event to message change log.""" + try: + channel = self.bot.get_channel(int(event.data["channel_id"])) + message = await channel.fetch_message(event.message_id) + except discord.NotFound: # Was deleted before we got the event + return + + if ( + not message.guild + or message.guild.id != GuildConstant.id + or message.channel.id in GuildConstant.modlog_blacklist + or message.author.bot + ): + return + + await asyncio.sleep(1) # Wait here in case the normal event was fired + + if event.message_id in self._cached_edits: + # It was in the cache and the normal event was fired, so we can just ignore it + self._cached_edits.remove(event.message_id) + return + + author = message.author + channel = message.channel + channel_name = f"{channel.category}/#{channel.name}" if channel.category else f"#{channel.name}" + + before_response = ( + f"**Author:** {author} (`{author.id}`)\n" + f"**Channel:** {channel_name} (`{channel.id}`)\n" + f"**Message ID:** `{message.id}`\n" + "\n" + "This message was not cached, so the message content cannot be displayed." + ) + + after_response = ( + f"**Author:** {author} (`{author.id}`)\n" + f"**Channel:** {channel_name} (`{channel.id}`)\n" + f"**Message ID:** `{message.id}`\n" + "\n" + f"{message.clean_content}" + ) + + await self.send_log_message( + Icons.message_edit, Colour.blurple(), "Message edited (Before)", + before_response, channel_id=Channels.message_log + ) + + await self.send_log_message( + Icons.message_edit, Colour.blurple(), "Message edited (After)", + after_response, channel_id=Channels.message_log + ) + + @Cog.listener() + async def on_voice_state_update( + self, + member: discord.Member, + before: discord.VoiceState, + after: discord.VoiceState + ) -> None: + """Log member voice state changes to the voice log channel.""" + if ( + member.guild.id != GuildConstant.id + or (before.channel and before.channel.id in GuildConstant.modlog_blacklist) + ): + return + + if member.id in self._ignored[Event.voice_state_update]: + self._ignored[Event.voice_state_update].remove(member.id) + return + + # Exclude all channel attributes except the name. + diff = DeepDiff( + before, + after, + exclude_paths=("root.session_id", "root.afk"), + exclude_regex_paths=r"root\.channel\.(?!name)", + ) + + # A type change seems to always take precedent over a value change. Furthermore, it will + # include the value change along with the type change anyway. Therefore, it's OK to + # "overwrite" values_changed; in practice there will never even be anything to overwrite. + diff_values = {**diff.get("values_changed", {}), **diff.get("type_changes", {})} + + icon = Icons.voice_state_blue + colour = Colour.blurple() + changes = [] + + for attr, values in diff_values.items(): + if not attr: # Not sure why, but it happens. + continue + + old = values["old_value"] + new = values["new_value"] + + attr = attr[5:] # Remove "root." prefix. + attr = VOICE_STATE_ATTRIBUTES.get(attr, attr.replace("_", " ").capitalize()) + + changes.append(f"**{attr}:** `{old}` **→** `{new}`") + + # Set the embed icon and colour depending on which attribute changed. + if any(name in attr for name in ("Channel", "deaf", "mute")): + if new is None or new is True: + # Left a channel or was muted/deafened. + icon = Icons.voice_state_red + colour = Colours.soft_red + elif old is None or old is True: + # Joined a channel or was unmuted/undeafened. + icon = Icons.voice_state_green + colour = Colours.soft_green + + if not changes: + return + + member_str = escape_markdown(str(member)) + message = "\n".join(f"{Emojis.bullet} {item}" for item in sorted(changes)) + message = f"**{member_str}** (`{member.id}`)\n{message}" + + await self.send_log_message( + icon_url=icon, + colour=colour, + title="Voice state updated", + text=message, + thumbnail=member.avatar_url_as(static_format="png"), + channel_id=Channels.voice_log + ) + + +def setup(bot: Bot) -> None: + """Load the ModLog cog.""" + bot.add_cog(ModLog(bot)) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py new file mode 100644 index 000000000..4af87c724 --- /dev/null +++ b/bot/exts/moderation/silence.py @@ -0,0 +1,170 @@ +import asyncio +import logging +from contextlib import suppress +from typing import Optional + +from discord import TextChannel +from discord.ext import commands, tasks +from discord.ext.commands import Context + +from bot.bot import Bot +from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles +from bot.converters import HushDurationConverter +from bot.utils.checks import with_role_check +from bot.utils.scheduling import Scheduler + +log = logging.getLogger(__name__) + + +class SilenceNotifier(tasks.Loop): + """Loop notifier for posting notices to `alert_channel` containing added channels.""" + + def __init__(self, alert_channel: TextChannel): + super().__init__(self._notifier, seconds=1, minutes=0, hours=0, count=None, reconnect=True, loop=None) + self._silenced_channels = {} + self._alert_channel = alert_channel + + def add_channel(self, channel: TextChannel) -> None: + """Add channel to `_silenced_channels` and start loop if not launched.""" + if not self._silenced_channels: + self.start() + log.info("Starting notifier loop.") + self._silenced_channels[channel] = self._current_loop + + def remove_channel(self, channel: TextChannel) -> None: + """Remove channel from `_silenced_channels` and stop loop if no channels remain.""" + with suppress(KeyError): + del self._silenced_channels[channel] + if not self._silenced_channels: + self.stop() + log.info("Stopping notifier loop.") + + async def _notifier(self) -> None: + """Post notice of `_silenced_channels` with their silenced duration to `_alert_channel` periodically.""" + # Wait for 15 minutes between notices with pause at start of loop. + if self._current_loop and not self._current_loop/60 % 15: + log.debug( + f"Sending notice with channels: " + f"{', '.join(f'#{channel} ({channel.id})' for channel in self._silenced_channels)}." + ) + channels_text = ', '.join( + f"{channel.mention} for {(self._current_loop-start)//60} min" + for channel, start in self._silenced_channels.items() + ) + await self._alert_channel.send(f"<@&{Roles.moderators}> currently silenced channels: {channels_text}") + + +class Silence(commands.Cog): + """Commands for stopping channel messages for `verified` role in a channel.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.scheduler = Scheduler(self.__class__.__name__) + self.muted_channels = set() + + self._get_instance_vars_task = self.bot.loop.create_task(self._get_instance_vars()) + self._get_instance_vars_event = asyncio.Event() + + async def _get_instance_vars(self) -> None: + """Get instance variables after they're available to get from the guild.""" + await self.bot.wait_until_guild_available() + guild = self.bot.get_guild(Guild.id) + self._verified_role = guild.get_role(Roles.verified) + self._mod_alerts_channel = self.bot.get_channel(Channels.mod_alerts) + self._mod_log_channel = self.bot.get_channel(Channels.mod_log) + self.notifier = SilenceNotifier(self._mod_log_channel) + self._get_instance_vars_event.set() + + @commands.command(aliases=("hush",)) + async def silence(self, ctx: Context, duration: HushDurationConverter = 10) -> None: + """ + Silence the current channel for `duration` minutes or `forever`. + + Duration is capped at 15 minutes, passing forever makes the silence indefinite. + Indefinitely silenced channels get added to a notifier which posts notices every 15 minutes from the start. + """ + await self._get_instance_vars_event.wait() + log.debug(f"{ctx.author} is silencing channel #{ctx.channel}.") + if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration): + await ctx.send(f"{Emojis.cross_mark} current channel is already silenced.") + return + if duration is None: + await ctx.send(f"{Emojis.check_mark} silenced current channel indefinitely.") + return + + await ctx.send(f"{Emojis.check_mark} silenced current channel for {duration} minute(s).") + + self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence)) + + @commands.command(aliases=("unhush",)) + async def unsilence(self, ctx: Context) -> None: + """ + Unsilence the current channel. + + If the channel was silenced indefinitely, notifications for the channel will stop. + """ + await self._get_instance_vars_event.wait() + log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") + if not await self._unsilence(ctx.channel): + await ctx.send(f"{Emojis.cross_mark} current channel was not silenced.") + else: + await ctx.send(f"{Emojis.check_mark} unsilenced current channel.") + + async def _silence(self, channel: TextChannel, persistent: bool, duration: Optional[int]) -> bool: + """ + Silence `channel` for `self._verified_role`. + + If `persistent` is `True` add `channel` to notifier. + `duration` is only used for logging; if None is passed `persistent` should be True to not log None. + Return `True` if channel permissions were changed, `False` otherwise. + """ + current_overwrite = channel.overwrites_for(self._verified_role) + if current_overwrite.send_messages is False: + log.info(f"Tried to silence channel #{channel} ({channel.id}) but the channel was already silenced.") + return False + await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=False)) + self.muted_channels.add(channel) + if persistent: + log.info(f"Silenced #{channel} ({channel.id}) indefinitely.") + self.notifier.add_channel(channel) + return True + + log.info(f"Silenced #{channel} ({channel.id}) for {duration} minute(s).") + return True + + async def _unsilence(self, channel: TextChannel) -> bool: + """ + Unsilence `channel`. + + Check if `channel` is silenced through a `PermissionOverwrite`, + if it is unsilence it and remove it from the notifier. + Return `True` if channel permissions were changed, `False` otherwise. + """ + current_overwrite = channel.overwrites_for(self._verified_role) + if current_overwrite.send_messages is False: + await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=None)) + log.info(f"Unsilenced channel #{channel} ({channel.id}).") + self.scheduler.cancel(channel.id) + self.notifier.remove_channel(channel) + self.muted_channels.discard(channel) + return True + log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") + return False + + def cog_unload(self) -> None: + """Send alert with silenced channels and cancel scheduled tasks on unload.""" + self.scheduler.cancel_all() + if self.muted_channels: + channels_string = ''.join(channel.mention for channel in self.muted_channels) + message = f"<@&{Roles.moderators}> channels left silenced on cog unload: {channels_string}" + asyncio.create_task(self._mod_alerts_channel.send(message)) + + # This cannot be static (must have a __func__ attribute). + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + return with_role_check(ctx, *MODERATION_ROLES) + + +def setup(bot: Bot) -> None: + """Load the Silence cog.""" + bot.add_cog(Silence(bot)) diff --git a/bot/exts/moderation/slowmode.py b/bot/exts/moderation/slowmode.py new file mode 100644 index 000000000..1d055afac --- /dev/null +++ b/bot/exts/moderation/slowmode.py @@ -0,0 +1,97 @@ +import logging +from datetime import datetime +from typing import Optional + +from dateutil.relativedelta import relativedelta +from discord import TextChannel +from discord.ext.commands import Cog, Context, group + +from bot.bot import Bot +from bot.constants import Emojis, MODERATION_ROLES +from bot.converters import DurationDelta +from bot.decorators import with_role_check +from bot.utils import time + +log = logging.getLogger(__name__) + +SLOWMODE_MAX_DELAY = 21600 # seconds + + +class Slowmode(Cog): + """Commands for getting and setting slowmode delays of text channels.""" + + def __init__(self, bot: Bot) -> None: + self.bot = bot + + @group(name='slowmode', aliases=['sm'], invoke_without_command=True) + async def slowmode_group(self, ctx: Context) -> None: + """Get or set the slowmode delay for the text channel this was invoked in or a given text channel.""" + await ctx.send_help(ctx.command) + + @slowmode_group.command(name='get', aliases=['g']) + async def get_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None: + """Get the slowmode delay for a text channel.""" + # Use the channel this command was invoked in if one was not given + if channel is None: + channel = ctx.channel + + delay = relativedelta(seconds=channel.slowmode_delay) + humanized_delay = time.humanize_delta(delay) + + await ctx.send(f'The slowmode delay for {channel.mention} is {humanized_delay}.') + + @slowmode_group.command(name='set', aliases=['s']) + async def set_slowmode(self, ctx: Context, channel: Optional[TextChannel], delay: DurationDelta) -> None: + """Set the slowmode delay for a text channel.""" + # Use the channel this command was invoked in if one was not given + if channel is None: + channel = ctx.channel + + # Convert `dateutil.relativedelta.relativedelta` to `datetime.timedelta` + # Must do this to get the delta in a particular unit of time + utcnow = datetime.utcnow() + slowmode_delay = (utcnow + delay - utcnow).total_seconds() + + humanized_delay = time.humanize_delta(delay) + + # Ensure the delay is within discord's limits + if slowmode_delay <= SLOWMODE_MAX_DELAY: + log.info(f'{ctx.author} set the slowmode delay for #{channel} to {humanized_delay}.') + + await channel.edit(slowmode_delay=slowmode_delay) + await ctx.send( + f'{Emojis.check_mark} The slowmode delay for {channel.mention} is now {humanized_delay}.' + ) + + else: + log.info( + f'{ctx.author} tried to set the slowmode delay of #{channel} to {humanized_delay}, ' + 'which is not between 0 and 6 hours.' + ) + + await ctx.send( + f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.' + ) + + @slowmode_group.command(name='reset', aliases=['r']) + async def reset_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None: + """Reset the slowmode delay for a text channel to 0 seconds.""" + # Use the channel this command was invoked in if one was not given + if channel is None: + channel = ctx.channel + + log.info(f'{ctx.author} reset the slowmode delay for #{channel} to 0 seconds.') + + await channel.edit(slowmode_delay=0) + await ctx.send( + f'{Emojis.check_mark} The slowmode delay for {channel.mention} has been reset to 0 seconds.' + ) + + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + return with_role_check(ctx, *MODERATION_ROLES) + + +def setup(bot: Bot) -> None: + """Load the Slowmode cog.""" + bot.add_cog(Slowmode(bot)) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py new file mode 100644 index 000000000..0db3e800d --- /dev/null +++ b/bot/exts/moderation/verification.py @@ -0,0 +1,191 @@ +import logging +from contextlib import suppress + +from discord import Colour, Forbidden, Message, NotFound, Object +from discord.ext.commands import Cog, Context, command + +from bot import constants +from bot.bot import Bot +from bot.decorators import in_whitelist, without_role +from bot.exts.moderation.modlog import ModLog +from bot.utils.checks import InWhitelistCheckFailure, without_role_check + +log = logging.getLogger(__name__) + +WELCOME_MESSAGE = f""" +Hello! Welcome to the server, and thanks for verifying yourself! + +For your records, these are the documents you accepted: + +`1)` Our rules, here: +`2)` Our privacy policy, here: - you can find information on how to have \ +your information removed here as well. + +Feel free to review them at any point! + +Additionally, if you'd like to receive notifications for the announcements \ +we post in <#{constants.Channels.announcements}> +from time to time, you can send `!subscribe` to <#{constants.Channels.bot_commands}> at any time \ +to assign yourself the **Announcements** role. We'll mention this role every time we make an announcement. + +If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to \ +<#{constants.Channels.bot_commands}>. +""" + +BOT_MESSAGE_DELETE_DELAY = 10 + + +class Verification(Cog): + """User verification and role self-management.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @property + def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + @Cog.listener() + async def on_message(self, message: Message) -> None: + """Check new message event for messages to the checkpoint channel & process.""" + if message.channel.id != constants.Channels.verification: + return # Only listen for #checkpoint messages + + if message.author.bot: + # They're a bot, delete their message after the delay. + await message.delete(delay=BOT_MESSAGE_DELETE_DELAY) + return + + # if a user mentions a role or guild member + # alert the mods in mod-alerts channel + if message.mentions or message.role_mentions: + log.debug( + f"{message.author} mentioned one or more users " + f"and/or roles in {message.channel.name}" + ) + + embed_text = ( + f"{message.author.mention} sent a message in " + f"{message.channel.mention} that contained user and/or role mentions." + f"\n\n**Original message:**\n>>> {message.content}" + ) + + # Send pretty mod log embed to mod-alerts + await self.mod_log.send_log_message( + icon_url=constants.Icons.filtering, + colour=Colour(constants.Colours.soft_red), + title=f"User/Role mentioned in {message.channel.name}", + text=embed_text, + thumbnail=message.author.avatar_url_as(static_format="png"), + channel_id=constants.Channels.mod_alerts, + ) + + ctx: Context = await self.bot.get_context(message) + if ctx.command is not None and ctx.command.name == "accept": + return + + if any(r.id == constants.Roles.verified for r in ctx.author.roles): + log.info( + f"{ctx.author} posted '{ctx.message.content}' " + "in the verification channel, but is already verified." + ) + return + + log.debug( + f"{ctx.author} posted '{ctx.message.content}' in the verification " + "channel. We are providing instructions how to verify." + ) + await ctx.send( + f"{ctx.author.mention} Please type `!accept` to verify that you accept our rules, " + f"and gain access to the rest of the server.", + delete_after=20 + ) + + log.trace(f"Deleting the message posted by {ctx.author}") + with suppress(NotFound): + await ctx.message.delete() + + @command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True) + @without_role(constants.Roles.verified) + @in_whitelist(channels=(constants.Channels.verification,)) + async def accept_command(self, ctx: Context, *_) -> None: # We don't actually care about the args + """Accept our rules and gain access to the rest of the server.""" + log.debug(f"{ctx.author} called !accept. Assigning the 'Developer' role.") + await ctx.author.add_roles(Object(constants.Roles.verified), reason="Accepted the rules") + try: + await ctx.author.send(WELCOME_MESSAGE) + except Forbidden: + log.info(f"Sending welcome message failed for {ctx.author}.") + finally: + log.trace(f"Deleting accept message by {ctx.author}.") + with suppress(NotFound): + self.mod_log.ignore(constants.Event.message_delete, ctx.message.id) + await ctx.message.delete() + + @command(name='subscribe') + @in_whitelist(channels=(constants.Channels.bot_commands,)) + async def subscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args + """Subscribe to announcement notifications by assigning yourself the role.""" + has_role = False + + for role in ctx.author.roles: + if role.id == constants.Roles.announcements: + has_role = True + break + + if has_role: + await ctx.send(f"{ctx.author.mention} You're already subscribed!") + return + + log.debug(f"{ctx.author} called !subscribe. Assigning the 'Announcements' role.") + await ctx.author.add_roles(Object(constants.Roles.announcements), reason="Subscribed to announcements") + + log.trace(f"Deleting the message posted by {ctx.author}.") + + await ctx.send( + f"{ctx.author.mention} Subscribed to <#{constants.Channels.announcements}> notifications.", + ) + + @command(name='unsubscribe') + @in_whitelist(channels=(constants.Channels.bot_commands,)) + async def unsubscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args + """Unsubscribe from announcement notifications by removing the role from yourself.""" + has_role = False + + for role in ctx.author.roles: + if role.id == constants.Roles.announcements: + has_role = True + break + + if not has_role: + await ctx.send(f"{ctx.author.mention} You're already unsubscribed!") + return + + log.debug(f"{ctx.author} called !unsubscribe. Removing the 'Announcements' role.") + await ctx.author.remove_roles(Object(constants.Roles.announcements), reason="Unsubscribed from announcements") + + log.trace(f"Deleting the message posted by {ctx.author}.") + + await ctx.send( + f"{ctx.author.mention} Unsubscribed from <#{constants.Channels.announcements}> notifications." + ) + + # This cannot be static (must have a __func__ attribute). + async def cog_command_error(self, ctx: Context, error: Exception) -> None: + """Check for & ignore any InWhitelistCheckFailure.""" + if isinstance(error, InWhitelistCheckFailure): + error.handled = True + + @staticmethod + def bot_check(ctx: Context) -> bool: + """Block any command within the verification channel that is not !accept.""" + if ctx.channel.id == constants.Channels.verification and without_role_check(ctx, *constants.MODERATION_ROLES): + return ctx.command.name == "accept" + else: + return True + + +def setup(bot: Bot) -> None: + """Load the Verification cog.""" + bot.add_cog(Verification(bot)) diff --git a/bot/exts/moderation/watchchannels/__init__.py b/bot/exts/moderation/watchchannels/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py new file mode 100644 index 000000000..013d3ee03 --- /dev/null +++ b/bot/exts/moderation/watchchannels/_watchchannel.py @@ -0,0 +1,348 @@ +import asyncio +import logging +import re +import textwrap +from abc import abstractmethod +from collections import defaultdict, deque +from dataclasses import dataclass +from typing import Optional + +import dateutil.parser +import discord +from discord import Color, DMChannel, Embed, HTTPException, Message, errors +from discord.ext.commands import Cog, Context + +from bot.api import ResponseCodeError +from bot.bot import Bot +from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons +from bot.exts.moderation.modlog import ModLog +from bot.pagination import LinePaginator +from bot.utils import CogABCMeta, messages +from bot.utils.time import time_since + +log = logging.getLogger(__name__) + +URL_RE = re.compile(r"(https?://[^\s]+)") + + +@dataclass +class MessageHistory: + """Represents a watch channel's message history.""" + + last_author: Optional[int] = None + last_channel: Optional[int] = None + message_count: int = 0 + + +class WatchChannel(metaclass=CogABCMeta): + """ABC with functionality for relaying users' messages to a certain channel.""" + + @abstractmethod + def __init__( + self, + bot: Bot, + destination: int, + webhook_id: int, + api_endpoint: str, + api_default_params: dict, + logger: logging.Logger + ) -> None: + self.bot = bot + + self.destination = destination # E.g., Channels.big_brother_logs + self.webhook_id = webhook_id # E.g., Webhooks.big_brother + self.api_endpoint = api_endpoint # E.g., 'bot/infractions' + self.api_default_params = api_default_params # E.g., {'active': 'true', 'type': 'watch'} + self.log = logger # Logger of the child cog for a correct name in the logs + + self._consume_task = None + self.watched_users = defaultdict(dict) + self.message_queue = defaultdict(lambda: defaultdict(deque)) + self.consumption_queue = {} + self.retries = 5 + self.retry_delay = 10 + self.channel = None + self.webhook = None + self.message_history = MessageHistory() + + self._start = self.bot.loop.create_task(self.start_watchchannel()) + + @property + def modlog(self) -> ModLog: + """Provides access to the ModLog cog for alert purposes.""" + return self.bot.get_cog("ModLog") + + @property + def consuming_messages(self) -> bool: + """Checks if a consumption task is currently running.""" + if self._consume_task is None: + return False + + if self._consume_task.done(): + exc = self._consume_task.exception() + if exc: + self.log.exception( + "The message queue consume task has failed with:", + exc_info=exc + ) + return False + + return True + + async def start_watchchannel(self) -> None: + """Starts the watch channel by getting the channel, webhook, and user cache ready.""" + await self.bot.wait_until_guild_available() + + try: + self.channel = await self.bot.fetch_channel(self.destination) + except HTTPException: + self.log.exception(f"Failed to retrieve the text channel with id `{self.destination}`") + + try: + self.webhook = await self.bot.fetch_webhook(self.webhook_id) + except discord.HTTPException: + self.log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") + + if self.channel is None or self.webhook is None: + self.log.error("Failed to start the watch channel; unloading the cog.") + + message = textwrap.dedent( + f""" + An error occurred while loading the text channel or webhook. + + TextChannel: {"**Failed to load**" if self.channel is None else "Loaded successfully"} + Webhook: {"**Failed to load**" if self.webhook is None else "Loaded successfully"} + + The Cog has been unloaded. + """ + ) + + await self.modlog.send_log_message( + title=f"Error: Failed to initialize the {self.__class__.__name__} watch channel", + text=message, + ping_everyone=True, + icon_url=Icons.token_removed, + colour=Color.red() + ) + + self.bot.remove_cog(self.__class__.__name__) + return + + if not await self.fetch_user_cache(): + await self.modlog.send_log_message( + title=f"Warning: Failed to retrieve user cache for the {self.__class__.__name__} watch channel", + text="Could not retrieve the list of watched users from the API and messages will not be relayed.", + ping_everyone=True, + icon_url=Icons.token_removed, + colour=Color.red() + ) + + async def fetch_user_cache(self) -> bool: + """ + Fetches watched users from the API and updates the watched user cache accordingly. + + This function returns `True` if the update succeeded. + """ + try: + data = await self.bot.api_client.get(self.api_endpoint, params=self.api_default_params) + except ResponseCodeError as err: + self.log.exception("Failed to fetch the watched users from the API", exc_info=err) + return False + + self.watched_users = defaultdict(dict) + + for entry in data: + user_id = entry.pop('user') + self.watched_users[user_id] = entry + + return True + + @Cog.listener() + async def on_message(self, msg: Message) -> None: + """Queues up messages sent by watched users.""" + if msg.author.id in self.watched_users: + if not self.consuming_messages: + self._consume_task = self.bot.loop.create_task(self.consume_messages()) + + self.log.trace(f"Received message: {msg.content} ({len(msg.attachments)} attachments)") + self.message_queue[msg.author.id][msg.channel.id].append(msg) + + async def consume_messages(self, delay_consumption: bool = True) -> None: + """Consumes the message queues to log watched users' messages.""" + if delay_consumption: + self.log.trace(f"Sleeping {BigBrotherConfig.log_delay} seconds before consuming message queue") + await asyncio.sleep(BigBrotherConfig.log_delay) + + self.log.trace("Started consuming the message queue") + + # If the previous consumption Task failed, first consume the existing comsumption_queue + if not self.consumption_queue: + self.consumption_queue = self.message_queue.copy() + self.message_queue.clear() + + for user_channel_queues in self.consumption_queue.values(): + for channel_queue in user_channel_queues.values(): + while channel_queue: + msg = channel_queue.popleft() + + self.log.trace(f"Consuming message {msg.id} ({len(msg.attachments)} attachments)") + await self.relay_message(msg) + + self.consumption_queue.clear() + + if self.message_queue: + self.log.trace("Channel queue not empty: Continuing consuming queues") + self._consume_task = self.bot.loop.create_task(self.consume_messages(delay_consumption=False)) + else: + self.log.trace("Done consuming messages.") + + async def webhook_send( + self, + content: Optional[str] = None, + username: Optional[str] = None, + avatar_url: Optional[str] = None, + embed: Optional[Embed] = None, + ) -> None: + """Sends a message to the webhook with the specified kwargs.""" + username = messages.sub_clyde(username) + try: + await self.webhook.send(content=content, username=username, avatar_url=avatar_url, embed=embed) + except discord.HTTPException as exc: + self.log.exception( + "Failed to send a message to the webhook", + exc_info=exc + ) + + async def relay_message(self, msg: Message) -> None: + """Relays the message to the relevant watch channel.""" + limit = BigBrotherConfig.header_message_limit + + if ( + msg.author.id != self.message_history.last_author + or msg.channel.id != self.message_history.last_channel + or self.message_history.message_count >= limit + ): + self.message_history = MessageHistory(last_author=msg.author.id, last_channel=msg.channel.id) + + await self.send_header(msg) + + cleaned_content = msg.clean_content + + if cleaned_content: + # Put all non-media URLs in a code block to prevent embeds + media_urls = {embed.url for embed in msg.embeds if embed.type in ("image", "video")} + for url in URL_RE.findall(cleaned_content): + if url not in media_urls: + cleaned_content = cleaned_content.replace(url, f"`{url}`") + await self.webhook_send( + cleaned_content, + username=msg.author.display_name, + avatar_url=msg.author.avatar_url + ) + + if msg.attachments: + try: + await messages.send_attachments(msg, self.webhook) + except (errors.Forbidden, errors.NotFound): + e = Embed( + description=":x: **This message contained an attachment, but it could not be retrieved**", + color=Color.red() + ) + await self.webhook_send( + embed=e, + username=msg.author.display_name, + avatar_url=msg.author.avatar_url + ) + except discord.HTTPException as exc: + self.log.exception( + "Failed to send an attachment to the webhook", + exc_info=exc + ) + + self.message_history.message_count += 1 + + async def send_header(self, msg: Message) -> None: + """Sends a header embed with information about the relayed messages to the watch channel.""" + user_id = msg.author.id + + guild = self.bot.get_guild(GuildConfig.id) + actor = guild.get_member(self.watched_users[user_id]['actor']) + actor = actor.display_name if actor else self.watched_users[user_id]['actor'] + + inserted_at = self.watched_users[user_id]['inserted_at'] + time_delta = self._get_time_delta(inserted_at) + + reason = self.watched_users[user_id]['reason'] + + if isinstance(msg.channel, DMChannel): + # If a watched user DMs the bot there won't be a channel name or jump URL + # This could technically include a GroupChannel but bot's can't be in those + message_jump = "via DM" + else: + message_jump = f"in [#{msg.channel.name}]({msg.jump_url})" + + footer = f"Added {time_delta} by {actor} | Reason: {reason}" + embed = Embed(description=f"{msg.author.mention} {message_jump}") + embed.set_footer(text=textwrap.shorten(footer, width=128, placeholder="...")) + + await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.avatar_url) + + async def list_watched_users( + self, ctx: Context, oldest_first: bool = False, update_cache: bool = True + ) -> None: + """ + Gives an overview of the watched user list for this channel. + + The optional kwarg `oldest_first` orders the list by oldest entry. + + The optional kwarg `update_cache` specifies whether the cache should + be refreshed by polling the API. + """ + if update_cache: + if not await self.fetch_user_cache(): + await ctx.send(f":x: Failed to update {self.__class__.__name__} user cache, serving from cache") + update_cache = False + + lines = [] + for user_id, user_data in self.watched_users.items(): + inserted_at = user_data['inserted_at'] + time_delta = self._get_time_delta(inserted_at) + lines.append(f"• <@{user_id}> (added {time_delta})") + + if oldest_first: + lines.reverse() + + lines = lines or ("There's nothing here yet.",) + + embed = Embed( + title=f"{self.__class__.__name__} watched users ({'updated' if update_cache else 'cached'})", + color=Color.blue() + ) + await LinePaginator.paginate(lines, ctx, embed, empty=False) + + @staticmethod + def _get_time_delta(time_string: str) -> str: + """Returns the time in human-readable time delta format.""" + date_time = dateutil.parser.isoparse(time_string).replace(tzinfo=None) + time_delta = time_since(date_time, precision="minutes", max_units=1) + + return time_delta + + def _remove_user(self, user_id: int) -> None: + """Removes a user from a watch channel.""" + self.watched_users.pop(user_id, None) + self.message_queue.pop(user_id, None) + self.consumption_queue.pop(user_id, None) + + def cog_unload(self) -> None: + """Takes care of unloading the cog and canceling the consumption task.""" + self.log.trace("Unloading the cog") + if self._consume_task and not self._consume_task.done(): + self._consume_task.cancel() + try: + self._consume_task.result() + except asyncio.CancelledError as e: + self.log.exception( + "The consume task was canceled. Messages may be lost.", + exc_info=e + ) diff --git a/bot/exts/moderation/watchchannels/bigbrother.py b/bot/exts/moderation/watchchannels/bigbrother.py new file mode 100644 index 000000000..4ac916c9e --- /dev/null +++ b/bot/exts/moderation/watchchannels/bigbrother.py @@ -0,0 +1,170 @@ +import logging +import textwrap +from collections import ChainMap + +from discord.ext.commands import Cog, Context, group + +from bot.bot import Bot +from bot.constants import Channels, MODERATION_ROLES, Webhooks +from bot.converters import FetchedMember +from bot.decorators import with_role +from bot.exts.moderation.infraction._utils import post_infraction +from ._watchchannel import WatchChannel + +log = logging.getLogger(__name__) + + +class BigBrother(WatchChannel, Cog, name="Big Brother"): + """Monitors users by relaying their messages to a watch channel to assist with moderation.""" + + def __init__(self, bot: Bot) -> None: + super().__init__( + bot, + destination=Channels.big_brother_logs, + webhook_id=Webhooks.big_brother, + api_endpoint='bot/infractions', + api_default_params={'active': 'true', 'type': 'watch', 'ordering': '-inserted_at'}, + logger=log + ) + + @group(name='bigbrother', aliases=('bb',), invoke_without_command=True) + @with_role(*MODERATION_ROLES) + async def bigbrother_group(self, ctx: Context) -> None: + """Monitors users by relaying their messages to the Big Brother watch channel.""" + await ctx.send_help(ctx.command) + + @bigbrother_group.command(name='watched', aliases=('all', 'list')) + @with_role(*MODERATION_ROLES) + async def watched_command( + self, ctx: Context, oldest_first: bool = False, update_cache: bool = True + ) -> None: + """ + Shows the users that are currently being monitored by Big Brother. + + The optional kwarg `oldest_first` can be used to order the list by oldest watched. + + The optional kwarg `update_cache` can be used to update the user + cache using the API before listing the users. + """ + await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) + + @bigbrother_group.command(name='oldest') + @with_role(*MODERATION_ROLES) + async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: + """ + Shows Big Brother monitored users ordered by oldest watched. + + The optional kwarg `update_cache` can be used to update the user + cache using the API before listing the users. + """ + await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) + + @bigbrother_group.command(name='watch', aliases=('w',)) + @with_role(*MODERATION_ROLES) + async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + """ + Relay messages sent by the given `user` to the `#big-brother` channel. + + A `reason` for adding the user to Big Brother is required and will be displayed + in the header when relaying messages of this user to the watchchannel. + """ + await self.apply_watch(ctx, user, reason) + + @bigbrother_group.command(name='unwatch', aliases=('uw',)) + @with_role(*MODERATION_ROLES) + async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + """Stop relaying messages by the given `user`.""" + await self.apply_unwatch(ctx, user, reason) + + async def apply_watch(self, ctx: Context, user: FetchedMember, reason: str) -> None: + """ + Add `user` to watched users and apply a watch infraction with `reason`. + + A message indicating the result of the operation is sent to `ctx`. + The message will include `user`'s previous watch infraction history, if it exists. + """ + if user.bot: + await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") + return + + if not await self.fetch_user_cache(): + await ctx.send(f":x: Updating the user cache failed, can't watch user {user}") + return + + if user.id in self.watched_users: + await ctx.send(f":x: {user} is already being watched.") + return + + response = await post_infraction(ctx, user, 'watch', reason, hidden=True, active=True) + + if response is not None: + self.watched_users[user.id] = response + msg = f":white_check_mark: Messages sent by {user} will now be relayed to Big Brother." + + history = await self.bot.api_client.get( + self.api_endpoint, + params={ + "user__id": str(user.id), + "active": "false", + 'type': 'watch', + 'ordering': '-inserted_at' + } + ) + + if len(history) > 1: + total = f"({len(history) // 2} previous infractions in total)" + end_reason = textwrap.shorten(history[0]["reason"], width=500, placeholder="...") + start_reason = f"Watched: {textwrap.shorten(history[1]['reason'], width=500, placeholder='...')}" + msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}\n\n{end_reason}```" + else: + msg = ":x: Failed to post the infraction: response was empty." + + await ctx.send(msg) + + async def apply_unwatch(self, ctx: Context, user: FetchedMember, reason: str, send_message: bool = True) -> None: + """ + Remove `user` from watched users and mark their infraction as inactive with `reason`. + + If `send_message` is True, a message indicating the result of the operation is sent to + `ctx`. + """ + active_watches = await self.bot.api_client.get( + self.api_endpoint, + params=ChainMap( + self.api_default_params, + {"user__id": str(user.id)} + ) + ) + if active_watches: + log.trace("Active watches for user found. Attempting to remove.") + [infraction] = active_watches + + await self.bot.api_client.patch( + f"{self.api_endpoint}/{infraction['id']}", + json={'active': False} + ) + + await post_infraction(ctx, user, 'watch', f"Unwatched: {reason}", hidden=True, active=False) + + self._remove_user(user.id) + + if not send_message: # Prevents a message being sent to the channel if part of a permanent ban + log.debug(f"Perma-banned user {user} was unwatched.") + return + log.trace("User is not banned. Sending message to channel") + message = f":white_check_mark: Messages sent by {user} will no longer be relayed." + + else: + log.trace("No active watches found for user.") + if not send_message: # Prevents a message being sent to the channel if part of a permanent ban + log.debug(f"{user} was not on the watch list; no removal necessary.") + return + log.trace("User is not perma banned. Send the error message.") + message = ":x: The specified user is currently not being watched." + + await ctx.send(message) + + +def setup(bot: Bot) -> None: + """Load the BigBrother cog.""" + bot.add_cog(BigBrother(bot)) diff --git a/bot/exts/moderation/watchchannels/talentpool.py b/bot/exts/moderation/watchchannels/talentpool.py new file mode 100644 index 000000000..2972f56e1 --- /dev/null +++ b/bot/exts/moderation/watchchannels/talentpool.py @@ -0,0 +1,269 @@ +import logging +import textwrap +from collections import ChainMap + +from discord import Color, Embed, Member +from discord.ext.commands import Cog, Context, group + +from bot.api import ResponseCodeError +from bot.bot import Bot +from bot.constants import Channels, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks +from bot.converters import FetchedMember +from bot.decorators import with_role +from bot.pagination import LinePaginator +from bot.utils import time +from ._watchchannel import WatchChannel + +log = logging.getLogger(__name__) + + +class TalentPool(WatchChannel, Cog, name="Talentpool"): + """Relays messages of helper candidates to a watch channel to observe them.""" + + def __init__(self, bot: Bot) -> None: + super().__init__( + bot, + destination=Channels.talent_pool, + webhook_id=Webhooks.talent_pool, + api_endpoint='bot/nominations', + api_default_params={'active': 'true', 'ordering': '-inserted_at'}, + logger=log, + ) + + @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True) + @with_role(*MODERATION_ROLES) + async def nomination_group(self, ctx: Context) -> None: + """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" + await ctx.send_help(ctx.command) + + @nomination_group.command(name='watched', aliases=('all', 'list')) + @with_role(*MODERATION_ROLES) + async def watched_command( + self, ctx: Context, oldest_first: bool = False, update_cache: bool = True + ) -> None: + """ + Shows the users that are currently being monitored in the talent pool. + + The optional kwarg `oldest_first` can be used to order the list by oldest nomination. + + The optional kwarg `update_cache` can be used to update the user + cache using the API before listing the users. + """ + await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) + + @nomination_group.command(name='oldest') + @with_role(*MODERATION_ROLES) + async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: + """ + Shows talent pool monitored users ordered by oldest nomination. + + The optional kwarg `update_cache` can be used to update the user + cache using the API before listing the users. + """ + await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) + + @nomination_group.command(name='watch', aliases=('w', 'add', 'a')) + @with_role(*STAFF_ROLES) + async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + """ + Relay messages sent by the given `user` to the `#talent-pool` channel. + + A `reason` for adding the user to the talent pool is required and will be displayed + in the header when relaying messages of this user to the channel. + """ + if user.bot: + await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") + return + + if isinstance(user, Member) and any(role.id in STAFF_ROLES for role in user.roles): + await ctx.send(":x: Nominating staff members, eh? Here's a cookie :cookie:") + return + + if not await self.fetch_user_cache(): + await ctx.send(f":x: Failed to update the user cache; can't add {user}") + return + + if user.id in self.watched_users: + await ctx.send(f":x: {user} is already being watched in the talent pool") + return + + # Manual request with `raise_for_status` as False because we want the actual response + session = self.bot.api_client.session + url = self.bot.api_client._url_for(self.api_endpoint) + kwargs = { + 'json': { + 'actor': ctx.author.id, + 'reason': reason, + 'user': user.id + }, + 'raise_for_status': False, + } + async with session.post(url, **kwargs) as resp: + response_data = await resp.json() + + if resp.status == 400 and response_data.get('user', False): + await ctx.send(":x: The specified user can't be found in the database tables") + return + else: + resp.raise_for_status() + + self.watched_users[user.id] = response_data + msg = f":white_check_mark: Messages sent by {user} will now be relayed to the talent pool channel" + + history = await self.bot.api_client.get( + self.api_endpoint, + params={ + "user__id": str(user.id), + "active": "false", + "ordering": "-inserted_at" + } + ) + + if history: + total = f"({len(history)} previous nominations in total)" + start_reason = f"Watched: {textwrap.shorten(history[0]['reason'], width=500, placeholder='...')}" + end_reason = f"Unwatched: {textwrap.shorten(history[0]['end_reason'], width=500, placeholder='...')}" + msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}\n\n{end_reason}```" + + await ctx.send(msg) + + @nomination_group.command(name='history', aliases=('info', 'search')) + @with_role(*MODERATION_ROLES) + async def history_command(self, ctx: Context, user: FetchedMember) -> None: + """Shows the specified user's nomination history.""" + result = await self.bot.api_client.get( + self.api_endpoint, + params={ + 'user__id': str(user.id), + 'ordering': "-active,-inserted_at" + } + ) + if not result: + await ctx.send(":warning: This user has never been nominated") + return + + embed = Embed( + title=f"Nominations for {user.display_name} `({user.id})`", + color=Color.blue() + ) + lines = [self._nomination_to_string(nomination) for nomination in result] + await LinePaginator.paginate( + lines, + ctx=ctx, + embed=embed, + empty=True, + max_lines=3, + max_size=1000 + ) + + @nomination_group.command(name='unwatch', aliases=('end', )) + @with_role(*MODERATION_ROLES) + async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + """ + Ends the active nomination of the specified user with the given reason. + + Providing a `reason` is required. + """ + active_nomination = await self.bot.api_client.get( + self.api_endpoint, + params=ChainMap( + self.api_default_params, + {"user__id": str(user.id)} + ) + ) + + if not active_nomination: + await ctx.send(":x: The specified user does not have an active nomination") + return + + [nomination] = active_nomination + await self.bot.api_client.patch( + f"{self.api_endpoint}/{nomination['id']}", + json={'end_reason': reason, 'active': False} + ) + await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed") + self._remove_user(user.id) + + @nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True) + @with_role(*MODERATION_ROLES) + async def nomination_edit_group(self, ctx: Context) -> None: + """Commands to edit nominations.""" + await ctx.send_help(ctx.command) + + @nomination_edit_group.command(name='reason') + @with_role(*MODERATION_ROLES) + async def edit_reason_command(self, ctx: Context, nomination_id: int, *, reason: str) -> None: + """ + Edits the reason/unnominate reason for the nomination with the given `id` depending on the status. + + If the nomination is active, the reason for nominating the user will be edited; + If the nomination is no longer active, the reason for ending the nomination will be edited instead. + """ + try: + nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}") + except ResponseCodeError as e: + if e.response.status == 404: + self.log.trace(f"Nomination API 404: Can't nomination with id {nomination_id}") + await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`") + return + else: + raise + + field = "reason" if nomination["active"] else "end_reason" + + self.log.trace(f"Changing {field} for nomination with id {nomination_id} to {reason}") + + await self.bot.api_client.patch( + f"{self.api_endpoint}/{nomination_id}", + json={field: reason} + ) + + await ctx.send(f":white_check_mark: Updated the {field} of the nomination!") + + def _nomination_to_string(self, nomination_object: dict) -> str: + """Creates a string representation of a nomination.""" + guild = self.bot.get_guild(Guild.id) + + actor_id = nomination_object["actor"] + actor = guild.get_member(actor_id) + + active = nomination_object["active"] + log.debug(active) + log.debug(type(nomination_object["inserted_at"])) + + start_date = time.format_infraction(nomination_object["inserted_at"]) + if active: + lines = textwrap.dedent( + f""" + =============== + Status: **Active** + Date: {start_date} + Actor: {actor.mention if actor else actor_id} + Reason: {nomination_object["reason"]} + Nomination ID: `{nomination_object["id"]}` + =============== + """ + ) + else: + end_date = time.format_infraction(nomination_object["ended_at"]) + lines = textwrap.dedent( + f""" + =============== + Status: Inactive + Date: {start_date} + Actor: {actor.mention if actor else actor_id} + Reason: {nomination_object["reason"]} + + End date: {end_date} + Unwatch reason: {nomination_object["end_reason"]} + Nomination ID: `{nomination_object["id"]}` + =============== + """ + ) + + return lines.strip() + + +def setup(bot: Bot) -> None: + """Load the TalentPool cog.""" + bot.add_cog(TalentPool(bot)) diff --git a/bot/exts/off_topic_names.py b/bot/exts/off_topic_names.py new file mode 100644 index 000000000..ce95450e0 --- /dev/null +++ b/bot/exts/off_topic_names.py @@ -0,0 +1,162 @@ +import asyncio +import difflib +import logging +from datetime import datetime, timedelta + +from discord import Colour, Embed +from discord.ext.commands import Cog, Context, group + +from bot.api import ResponseCodeError +from bot.bot import Bot +from bot.constants import Channels, MODERATION_ROLES +from bot.converters import OffTopicName +from bot.decorators import with_role +from bot.pagination import LinePaginator + +CHANNELS = (Channels.off_topic_0, Channels.off_topic_1, Channels.off_topic_2) +log = logging.getLogger(__name__) + + +async def update_names(bot: Bot) -> None: + """Background updater task that performs the daily channel name update.""" + while True: + # Since we truncate the compute timedelta to seconds, we add one second to ensure + # we go past midnight in the `seconds_to_sleep` set below. + today_at_midnight = datetime.utcnow().replace(microsecond=0, second=0, minute=0, hour=0) + next_midnight = today_at_midnight + timedelta(days=1) + seconds_to_sleep = (next_midnight - datetime.utcnow()).seconds + 1 + await asyncio.sleep(seconds_to_sleep) + + try: + channel_0_name, channel_1_name, channel_2_name = await bot.api_client.get( + 'bot/off-topic-channel-names', params={'random_items': 3} + ) + except ResponseCodeError as e: + log.error(f"Failed to get new off topic channel names: code {e.response.status}") + continue + channel_0, channel_1, channel_2 = (bot.get_channel(channel_id) for channel_id in CHANNELS) + + await channel_0.edit(name=f'ot0-{channel_0_name}') + await channel_1.edit(name=f'ot1-{channel_1_name}') + await channel_2.edit(name=f'ot2-{channel_2_name}') + log.debug( + "Updated off-topic channel names to" + f" {channel_0_name}, {channel_1_name} and {channel_2_name}" + ) + + +class OffTopicNames(Cog): + """Commands related to managing the off-topic category channel names.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.updater_task = None + + self.bot.loop.create_task(self.init_offtopic_updater()) + + def cog_unload(self) -> None: + """Cancel any running updater tasks on cog unload.""" + if self.updater_task is not None: + self.updater_task.cancel() + + async def init_offtopic_updater(self) -> None: + """Start off-topic channel updating event loop if it hasn't already started.""" + await self.bot.wait_until_guild_available() + if self.updater_task is None: + coro = update_names(self.bot) + self.updater_task = self.bot.loop.create_task(coro) + + @group(name='otname', aliases=('otnames', 'otn'), invoke_without_command=True) + @with_role(*MODERATION_ROLES) + async def otname_group(self, ctx: Context) -> None: + """Add or list items from the off-topic channel name rotation.""" + await ctx.send_help(ctx.command) + + @otname_group.command(name='add', aliases=('a',)) + @with_role(*MODERATION_ROLES) + async def add_command(self, ctx: Context, *, name: OffTopicName) -> None: + """ + Adds a new off-topic name to the rotation. + + The name is not added if it is too similar to an existing name. + """ + existing_names = await self.bot.api_client.get('bot/off-topic-channel-names') + close_match = difflib.get_close_matches(name, existing_names, n=1, cutoff=0.8) + + if close_match: + match = close_match[0] + log.info( + f"{ctx.author} tried to add channel name '{name}' but it was too similar to '{match}'" + ) + await ctx.send( + f":x: The channel name `{name}` is too similar to `{match}`, and thus was not added. " + "Use `!otn forceadd` to override this check." + ) + else: + await self._add_name(ctx, name) + + @otname_group.command(name='forceadd', aliases=('fa',)) + @with_role(*MODERATION_ROLES) + async def force_add_command(self, ctx: Context, *, name: OffTopicName) -> None: + """Forcefully adds a new off-topic name to the rotation.""" + await self._add_name(ctx, name) + + async def _add_name(self, ctx: Context, name: str) -> None: + """Adds an off-topic channel name to the site storage.""" + await self.bot.api_client.post('bot/off-topic-channel-names', params={'name': name}) + + log.info(f"{ctx.author} added the off-topic channel name '{name}'") + await ctx.send(f":ok_hand: Added `{name}` to the names list.") + + @otname_group.command(name='delete', aliases=('remove', 'rm', 'del', 'd')) + @with_role(*MODERATION_ROLES) + async def delete_command(self, ctx: Context, *, name: OffTopicName) -> None: + """Removes a off-topic name from the rotation.""" + await self.bot.api_client.delete(f'bot/off-topic-channel-names/{name}') + + log.info(f"{ctx.author} deleted the off-topic channel name '{name}'") + await ctx.send(f":ok_hand: Removed `{name}` from the names list.") + + @otname_group.command(name='list', aliases=('l',)) + @with_role(*MODERATION_ROLES) + async def list_command(self, ctx: Context) -> None: + """ + Lists all currently known off-topic channel names in a paginator. + + Restricted to Moderator and above to not spoil the surprise. + """ + result = await self.bot.api_client.get('bot/off-topic-channel-names') + lines = sorted(f"• {name}" for name in result) + embed = Embed( + title=f"Known off-topic names (`{len(result)}` total)", + colour=Colour.blue() + ) + if result: + await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False) + else: + embed.description = "Hmmm, seems like there's nothing here yet." + await ctx.send(embed=embed) + + @otname_group.command(name='search', aliases=('s',)) + @with_role(*MODERATION_ROLES) + async def search_command(self, ctx: Context, *, query: OffTopicName) -> None: + """Search for an off-topic name.""" + result = await self.bot.api_client.get('bot/off-topic-channel-names') + in_matches = {name for name in result if query in name} + close_matches = difflib.get_close_matches(query, result, n=10, cutoff=0.70) + lines = sorted(f"• {name}" for name in in_matches.union(close_matches)) + embed = Embed( + title="Query results", + colour=Colour.blue() + ) + + if lines: + await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False) + else: + embed.description = "Nothing found." + await ctx.send(embed=embed) + + +def setup(bot: Bot) -> None: + """Load the OffTopicNames cog.""" + bot.add_cog(OffTopicNames(bot)) diff --git a/bot/exts/utils/__init__.py b/bot/exts/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bot/exts/utils/bot.py b/bot/exts/utils/bot.py new file mode 100644 index 000000000..866fd2b68 --- /dev/null +++ b/bot/exts/utils/bot.py @@ -0,0 +1,385 @@ +import ast +import logging +import re +import time +from typing import Optional, Tuple + +from discord import Embed, Message, RawMessageUpdateEvent, TextChannel +from discord.ext.commands import Cog, Context, command, group + +from bot.bot import Bot +from bot.constants import Categories, Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs +from bot.decorators import with_role +from bot.exts.filters.token_remover import TokenRemover +from bot.utils.messages import wait_for_deletion + +log = logging.getLogger(__name__) + +RE_MARKDOWN = re.compile(r'([*_~`|>])') + + +class BotCog(Cog, name="Bot"): + """Bot information commands.""" + + def __init__(self, bot: Bot): + self.bot = bot + + # Stores allowed channels plus epoch time since last call. + self.channel_cooldowns = { + Channels.python_discussion: 0, + } + + # These channels will also work, but will not be subject to cooldown + self.channel_whitelist = ( + Channels.bot_commands, + ) + + # Stores improperly formatted Python codeblock message ids and the corresponding bot message + self.codeblock_message_ids = {} + + @group(invoke_without_command=True, name="bot", hidden=True) + @with_role(Roles.verified) + async def botinfo_group(self, ctx: Context) -> None: + """Bot informational commands.""" + await ctx.send_help(ctx.command) + + @botinfo_group.command(name='about', aliases=('info',), hidden=True) + @with_role(Roles.verified) + async def about_command(self, ctx: Context) -> None: + """Get information about the bot.""" + embed = Embed( + description="A utility bot designed just for the Python server! Try `!help` for more info.", + url="https://github.com/python-discord/bot" + ) + + embed.add_field(name="Total Users", value=str(len(self.bot.get_guild(Guild.id).members))) + embed.set_author( + name="Python Bot", + url="https://github.com/python-discord/bot", + icon_url=URLs.bot_avatar + ) + + await ctx.send(embed=embed) + + @command(name='echo', aliases=('print',)) + @with_role(*MODERATION_ROLES) + async def echo_command(self, ctx: Context, channel: Optional[TextChannel], *, text: str) -> None: + """Repeat the given message in either a specified channel or the current channel.""" + if channel is None: + await ctx.send(text) + else: + await channel.send(text) + + @command(name='embed') + @with_role(*MODERATION_ROLES) + async def embed_command(self, ctx: Context, channel: Optional[TextChannel], *, text: str) -> None: + """Send the input within an embed to either a specified channel or the current channel.""" + embed = Embed(description=text) + + if channel is None: + await ctx.send(embed=embed) + else: + await channel.send(embed=embed) + + def codeblock_stripping(self, msg: str, bad_ticks: bool) -> Optional[Tuple[Tuple[str, ...], str]]: + """ + Strip msg in order to find Python code. + + Tries to strip out Python code out of msg and returns the stripped block or + None if the block is a valid Python codeblock. + """ + if msg.count("\n") >= 3: + # Filtering valid Python codeblocks and exiting if a valid Python codeblock is found. + if re.search("```(?:py|python)\n(.*?)```", msg, re.IGNORECASE | re.DOTALL) and not bad_ticks: + log.trace( + "Someone wrote a message that was already a " + "valid Python syntax highlighted code block. No action taken." + ) + return None + + else: + # Stripping backticks from every line of the message. + log.trace(f"Stripping backticks from message.\n\n{msg}\n\n") + content = "" + for line in msg.splitlines(keepends=True): + content += line.strip("`") + + content = content.strip() + + # Remove "Python" or "Py" from start of the message if it exists. + log.trace(f"Removing 'py' or 'python' from message.\n\n{content}\n\n") + pycode = False + if content.lower().startswith("python"): + content = content[6:] + pycode = True + elif content.lower().startswith("py"): + content = content[2:] + pycode = True + + if pycode: + content = content.splitlines(keepends=True) + + # Check if there might be code in the first line, and preserve it. + first_line = content[0] + if " " in content[0]: + first_space = first_line.index(" ") + content[0] = first_line[first_space:] + content = "".join(content) + + # If there's no code we can just get rid of the first line. + else: + content = "".join(content[1:]) + + # Strip it again to remove any leading whitespace. This is neccessary + # if the first line of the message looked like ```python + old = content.strip() + + # Strips REPL code out of the message if there is any. + content, repl_code = self.repl_stripping(old) + if old != content: + return (content, old), repl_code + + # Try to apply indentation fixes to the code. + content = self.fix_indentation(content) + + # Check if the code contains backticks, if it does ignore the message. + if "`" in content: + log.trace("Detected ` inside the code, won't reply") + return None + else: + log.trace(f"Returning message.\n\n{content}\n\n") + return (content,), repl_code + + def fix_indentation(self, msg: str) -> str: + """Attempts to fix badly indented code.""" + def unindent(code: str, skip_spaces: int = 0) -> str: + """Unindents all code down to the number of spaces given in skip_spaces.""" + final = "" + current = code[0] + leading_spaces = 0 + + # Get numbers of spaces before code in the first line. + while current == " ": + current = code[leading_spaces + 1] + leading_spaces += 1 + leading_spaces -= skip_spaces + + # If there are any, remove that number of spaces from every line. + if leading_spaces > 0: + for line in code.splitlines(keepends=True): + line = line[leading_spaces:] + final += line + return final + else: + return code + + # Apply fix for "all lines are overindented" case. + msg = unindent(msg) + + # If the first line does not end with a colon, we can be + # certain the next line will be on the same indentation level. + # + # If it does end with a colon, we will need to indent all successive + # lines one additional level. + first_line = msg.splitlines()[0] + code = "".join(msg.splitlines(keepends=True)[1:]) + if not first_line.endswith(":"): + msg = f"{first_line}\n{unindent(code)}" + else: + msg = f"{first_line}\n{unindent(code, 4)}" + return msg + + def repl_stripping(self, msg: str) -> Tuple[str, bool]: + """ + Strip msg in order to extract Python code out of REPL output. + + Tries to strip out REPL Python code out of msg and returns the stripped msg. + + Returns True for the boolean if REPL code was found in the input msg. + """ + final = "" + for line in msg.splitlines(keepends=True): + if line.startswith(">>>") or line.startswith("..."): + final += line[4:] + log.trace(f"Formatted: \n\n{msg}\n\n to \n\n{final}\n\n") + if not final: + log.trace(f"Found no REPL code in \n\n{msg}\n\n") + return msg, False + else: + log.trace(f"Found REPL code in \n\n{msg}\n\n") + return final.rstrip(), True + + def has_bad_ticks(self, msg: Message) -> bool: + """Check to see if msg contains ticks that aren't '`'.""" + not_backticks = [ + "'''", '"""', "\u00b4\u00b4\u00b4", "\u2018\u2018\u2018", "\u2019\u2019\u2019", + "\u2032\u2032\u2032", "\u201c\u201c\u201c", "\u201d\u201d\u201d", "\u2033\u2033\u2033", + "\u3003\u3003\u3003" + ] + + return msg.content[:3] in not_backticks + + @Cog.listener() + async def on_message(self, msg: Message) -> None: + """ + Detect poorly formatted Python code in new messages. + + If poorly formatted code is detected, send the user a helpful message explaining how to do + properly formatted Python syntax highlighting codeblocks. + """ + is_help_channel = ( + getattr(msg.channel, "category", None) + and msg.channel.category.id in (Categories.help_available, Categories.help_in_use) + ) + parse_codeblock = ( + ( + is_help_channel + or msg.channel.id in self.channel_cooldowns + or msg.channel.id in self.channel_whitelist + ) + and not msg.author.bot + and len(msg.content.splitlines()) > 3 + and not TokenRemover.find_token_in_message(msg) + ) + + if parse_codeblock: # no token in the msg + on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300 + if not on_cooldown or DEBUG_MODE: + try: + if self.has_bad_ticks(msg): + ticks = msg.content[:3] + content = self.codeblock_stripping(f"```{msg.content[3:-3]}```", True) + if content is None: + return + + content, repl_code = content + + if len(content) == 2: + content = content[1] + else: + content = content[0] + + space_left = 204 + if len(content) >= space_left: + current_length = 0 + lines_walked = 0 + for line in content.splitlines(keepends=True): + if current_length + len(line) > space_left or lines_walked == 10: + break + current_length += len(line) + lines_walked += 1 + content = content[:current_length] + "#..." + content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + howto = ( + "It looks like you are trying to paste code into this channel.\n\n" + "You seem to be using the wrong symbols to indicate where the codeblock should start. " + f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n" + "**Here is an example of how it should look:**\n" + f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" + f"```python\n{content}\n```" + ) + + else: + howto = "" + content = self.codeblock_stripping(msg.content, False) + if content is None: + return + + content, repl_code = content + # Attempts to parse the message into an AST node. + # Invalid Python code will raise a SyntaxError. + tree = ast.parse(content[0]) + + # Multiple lines of single words could be interpreted as expressions. + # This check is to avoid all nodes being parsed as expressions. + # (e.g. words over multiple lines) + if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: + # Shorten the code to 10 lines and/or 204 characters. + space_left = 204 + if content and repl_code: + content = content[1] + else: + content = content[0] + + if len(content) >= space_left: + current_length = 0 + lines_walked = 0 + for line in content.splitlines(keepends=True): + if current_length + len(line) > space_left or lines_walked == 10: + break + current_length += len(line) + lines_walked += 1 + content = content[:current_length] + "#..." + + content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + howto += ( + "It looks like you're trying to paste code into this channel.\n\n" + "Discord has support for Markdown, which allows you to post code with full " + "syntax highlighting. Please use these whenever you paste code, as this " + "helps improve the legibility and makes it easier for us to help you.\n\n" + f"**To do this, use the following method:**\n" + f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" + f"```python\n{content}\n```" + ) + + log.debug(f"{msg.author} posted something that needed to be put inside python code " + "blocks. Sending the user some instructions.") + else: + log.trace("The code consists only of expressions, not sending instructions") + + if howto != "": + # Increase amount of codeblock correction in stats + self.bot.stats.incr("codeblock_corrections") + howto_embed = Embed(description=howto) + bot_message = await msg.channel.send(f"Hey {msg.author.mention}!", embed=howto_embed) + self.codeblock_message_ids[msg.id] = bot_message.id + + self.bot.loop.create_task( + wait_for_deletion(bot_message, user_ids=(msg.author.id,), client=self.bot) + ) + else: + return + + if msg.channel.id not in self.channel_whitelist: + self.channel_cooldowns[msg.channel.id] = time.time() + + except SyntaxError: + log.trace( + f"{msg.author} posted in a help channel, and when we tried to parse it as Python code, " + "ast.parse raised a SyntaxError. This probably just means it wasn't Python code. " + f"The message that was posted was:\n\n{msg.content}\n\n" + ) + + @Cog.listener() + async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: + """Check to see if an edited message (previously called out) still contains poorly formatted code.""" + if ( + # Checks to see if the message was called out by the bot + payload.message_id not in self.codeblock_message_ids + # Makes sure that there is content in the message + or payload.data.get("content") is None + # Makes sure there's a channel id in the message payload + or payload.data.get("channel_id") is None + ): + return + + # Retrieve channel and message objects for use later + channel = self.bot.get_channel(int(payload.data.get("channel_id"))) + user_message = await channel.fetch_message(payload.message_id) + + # Checks to see if the user has corrected their codeblock. If it's fixed, has_fixed_codeblock will be None + has_fixed_codeblock = self.codeblock_stripping(payload.data.get("content"), self.has_bad_ticks(user_message)) + + # If the message is fixed, delete the bot message and the entry from the id dictionary + if has_fixed_codeblock is None: + bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) + await bot_message.delete() + del self.codeblock_message_ids[payload.message_id] + log.trace("User's incorrect code block has been fixed. Removing bot formatting message.") + + +def setup(bot: Bot) -> None: + """Load the Bot cog.""" + bot.add_cog(BotCog(bot)) diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py new file mode 100644 index 000000000..d9a7aafe1 --- /dev/null +++ b/bot/exts/utils/clean.py @@ -0,0 +1,272 @@ +import logging +import random +import re +from typing import Iterable, Optional + +from discord import Colour, Embed, Message, TextChannel, User +from discord.ext import commands +from discord.ext.commands import Cog, Context, group + +from bot.bot import Bot +from bot.constants import ( + Channels, CleanMessages, Colours, Event, Icons, MODERATION_ROLES, NEGATIVE_REPLIES +) +from bot.decorators import with_role +from bot.exts.moderation.modlog import ModLog + +log = logging.getLogger(__name__) + + +class Clean(Cog): + """ + A cog that allows messages to be deleted in bulk, while applying various filters. + + You can delete messages sent by a specific user, messages sent by bots, all messages, or messages that match a + specific regular expression. + + The deleted messages are saved and uploaded to the database via an API endpoint, and a URL is returned which can be + used to view the messages in the Discord dark theme style. + """ + + def __init__(self, bot: Bot): + self.bot = bot + self.cleaning = False + + @property + def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + async def _clean_messages( + self, + amount: int, + ctx: Context, + channels: Iterable[TextChannel], + bots_only: bool = False, + user: User = None, + regex: Optional[str] = None, + until_message: Optional[Message] = None, + ) -> None: + """A helper function that does the actual message cleaning.""" + def predicate_bots_only(message: Message) -> bool: + """Return True if the message was sent by a bot.""" + return message.author.bot + + def predicate_specific_user(message: Message) -> bool: + """Return True if the message was sent by the user provided in the _clean_messages call.""" + return message.author == user + + def predicate_regex(message: Message) -> bool: + """Check if the regex provided in _clean_messages matches the message content or any embed attributes.""" + content = [message.content] + + # Add the content for all embed attributes + for embed in message.embeds: + content.append(embed.title) + content.append(embed.description) + content.append(embed.footer.text) + content.append(embed.author.name) + for field in embed.fields: + content.append(field.name) + content.append(field.value) + + # Get rid of empty attributes and turn it into a string + content = [attr for attr in content if attr] + content = "\n".join(content) + + # Now let's see if there's a regex match + if not content: + return False + else: + return bool(re.search(regex.lower(), content.lower())) + + # Is this an acceptable amount of messages to clean? + if amount > CleanMessages.message_limit: + embed = Embed( + color=Colour(Colours.soft_red), + title=random.choice(NEGATIVE_REPLIES), + description=f"You cannot clean more than {CleanMessages.message_limit} messages." + ) + await ctx.send(embed=embed) + return + + # Are we already performing a clean? + if self.cleaning: + embed = Embed( + color=Colour(Colours.soft_red), + title=random.choice(NEGATIVE_REPLIES), + description="Please wait for the currently ongoing clean operation to complete." + ) + await ctx.send(embed=embed) + return + + # Set up the correct predicate + if bots_only: + predicate = predicate_bots_only # Delete messages from bots + elif user: + predicate = predicate_specific_user # Delete messages from specific user + elif regex: + predicate = predicate_regex # Delete messages that match regex + else: + predicate = None # Delete all messages + + # Default to using the invoking context's channel + if not channels: + channels = [ctx.channel] + + # Delete the invocation first + self.mod_log.ignore(Event.message_delete, ctx.message.id) + await ctx.message.delete() + + messages = [] + message_ids = [] + self.cleaning = True + + # Find the IDs of the messages to delete. IDs are needed in order to ignore mod log events. + for channel in channels: + async for message in channel.history(limit=amount): + + # If at any point the cancel command is invoked, we should stop. + if not self.cleaning: + return + + # If we are looking for specific message. + if until_message: + + # we could use ID's here however in case if the message we are looking for gets deleted, + # we won't have a way to figure that out thus checking for datetime should be more reliable + if message.created_at < until_message.created_at: + # means we have found the message until which we were supposed to be deleting. + break + + # Since we will be using `delete_messages` method of a TextChannel and we need message objects to + # use it as well as to send logs we will start appending messages here instead adding them from + # purge. + messages.append(message) + + # If the message passes predicate, let's save it. + if predicate is None or predicate(message): + message_ids.append(message.id) + + self.cleaning = False + + # Now let's delete the actual messages with purge. + self.mod_log.ignore(Event.message_delete, *message_ids) + for channel in channels: + if until_message: + for i in range(0, len(messages), 100): + # while purge automatically handles the amount of messages + # delete_messages only allows for up to 100 messages at once + # thus we need to paginate the amount to always be <= 100 + await channel.delete_messages(messages[i:i + 100]) + else: + messages += await channel.purge(limit=amount, check=predicate) + + # Reverse the list to restore chronological order + if messages: + messages = reversed(messages) + log_url = await self.mod_log.upload_log(messages, ctx.author.id) + else: + # Can't build an embed, nothing to clean! + embed = Embed( + color=Colour(Colours.soft_red), + description="No matching messages could be found." + ) + await ctx.send(embed=embed, delete_after=10) + return + + # Build the embed and send it + target_channels = ", ".join(channel.mention for channel in channels) + + message = ( + f"**{len(message_ids)}** messages deleted in {target_channels} by **{ctx.author.name}**\n\n" + f"A log of the deleted messages can be found [here]({log_url})." + ) + + await self.mod_log.send_log_message( + icon_url=Icons.message_bulk_delete, + colour=Colour(Colours.soft_red), + title="Bulk message delete", + text=message, + channel_id=Channels.mod_log, + ) + + @group(invoke_without_command=True, name="clean", aliases=["purge"]) + @with_role(*MODERATION_ROLES) + async def clean_group(self, ctx: Context) -> None: + """Commands for cleaning messages in channels.""" + await ctx.send_help(ctx.command) + + @clean_group.command(name="user", aliases=["users"]) + @with_role(*MODERATION_ROLES) + async def clean_user( + self, + ctx: Context, + user: User, + amount: Optional[int] = 10, + channels: commands.Greedy[TextChannel] = None + ) -> None: + """Delete messages posted by the provided user, stop cleaning after traversing `amount` messages.""" + await self._clean_messages(amount, ctx, user=user, channels=channels) + + @clean_group.command(name="all", aliases=["everything"]) + @with_role(*MODERATION_ROLES) + async def clean_all( + self, + ctx: Context, + amount: Optional[int] = 10, + channels: commands.Greedy[TextChannel] = None + ) -> None: + """Delete all messages, regardless of poster, stop cleaning after traversing `amount` messages.""" + await self._clean_messages(amount, ctx, channels=channels) + + @clean_group.command(name="bots", aliases=["bot"]) + @with_role(*MODERATION_ROLES) + async def clean_bots( + self, + ctx: Context, + amount: Optional[int] = 10, + channels: commands.Greedy[TextChannel] = None + ) -> None: + """Delete all messages posted by a bot, stop cleaning after traversing `amount` messages.""" + await self._clean_messages(amount, ctx, bots_only=True, channels=channels) + + @clean_group.command(name="regex", aliases=["word", "expression"]) + @with_role(*MODERATION_ROLES) + async def clean_regex( + self, + ctx: Context, + regex: str, + amount: Optional[int] = 10, + channels: commands.Greedy[TextChannel] = None + ) -> None: + """Delete all messages that match a certain regex, stop cleaning after traversing `amount` messages.""" + await self._clean_messages(amount, ctx, regex=regex, channels=channels) + + @clean_group.command(name="message", aliases=["messages"]) + @with_role(*MODERATION_ROLES) + async def clean_message(self, ctx: Context, message: Message) -> None: + """Delete all messages until certain message, stop cleaning after hitting the `message`.""" + await self._clean_messages( + CleanMessages.message_limit, + ctx, + channels=[message.channel], + until_message=message + ) + + @clean_group.command(name="stop", aliases=["cancel", "abort"]) + @with_role(*MODERATION_ROLES) + async def clean_cancel(self, ctx: Context) -> None: + """If there is an ongoing cleaning process, attempt to immediately cancel it.""" + self.cleaning = False + + embed = Embed( + color=Colour.blurple(), + description="Clean interrupted." + ) + await ctx.send(embed=embed, delete_after=10) + + +def setup(bot: Bot) -> None: + """Load the Clean cog.""" + bot.add_cog(Clean(bot)) diff --git a/bot/exts/utils/eval.py b/bot/exts/utils/eval.py new file mode 100644 index 000000000..eb8bfb1cf --- /dev/null +++ b/bot/exts/utils/eval.py @@ -0,0 +1,202 @@ +import contextlib +import inspect +import logging +import pprint +import re +import textwrap +import traceback +from io import StringIO +from typing import Any, Optional, Tuple + +import discord +from discord.ext.commands import Cog, Context, group + +from bot.bot import Bot +from bot.constants import Roles +from bot.decorators import with_role +from bot.interpreter import Interpreter + +log = logging.getLogger(__name__) + + +class CodeEval(Cog): + """Owner and admin feature that evaluates code and returns the result to the channel.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.env = {} + self.ln = 0 + self.stdout = StringIO() + + self.interpreter = Interpreter(bot) + + def _format(self, inp: str, out: Any) -> Tuple[str, Optional[discord.Embed]]: + """Format the eval output into a string & attempt to format it into an Embed.""" + self._ = out + + res = "" + + # Erase temp input we made + if inp.startswith("_ = "): + inp = inp[4:] + + # Get all non-empty lines + lines = [line for line in inp.split("\n") if line.strip()] + if len(lines) != 1: + lines += [""] + + # Create the input dialog + for i, line in enumerate(lines): + if i == 0: + # Start dialog + start = f"In [{self.ln}]: " + + else: + # Indent the 3 dots correctly; + # Normally, it's something like + # In [X]: + # ...: + # + # But if it's + # In [XX]: + # ...: + # + # You can see it doesn't look right. + # This code simply indents the dots + # far enough to align them. + # we first `str()` the line number + # then we get the length + # and use `str.rjust()` + # to indent it. + start = "...: ".rjust(len(str(self.ln)) + 7) + + if i == len(lines) - 2: + if line.startswith("return"): + line = line[6:].strip() + + # Combine everything + res += (start + line + "\n") + + self.stdout.seek(0) + text = self.stdout.read() + self.stdout.close() + self.stdout = StringIO() + + if text: + res += (text + "\n") + + if out is None: + # No output, return the input statement + return (res, None) + + res += f"Out[{self.ln}]: " + + if isinstance(out, discord.Embed): + # We made an embed? Send that as embed + res += "" + res = (res, out) + + else: + if (isinstance(out, str) and out.startswith("Traceback (most recent call last):\n")): + # Leave out the traceback message + out = "\n" + "\n".join(out.split("\n")[1:]) + + if isinstance(out, str): + pretty = out + else: + pretty = pprint.pformat(out, compact=True, width=60) + + if pretty != str(out): + # We're using the pretty version, start on the next line + res += "\n" + + if pretty.count("\n") > 20: + # Text too long, shorten + li = pretty.split("\n") + + pretty = ("\n".join(li[:3]) # First 3 lines + + "\n ...\n" # Ellipsis to indicate removed lines + + "\n".join(li[-3:])) # last 3 lines + + # Add the output + res += pretty + res = (res, None) + + return res # Return (text, embed) + + async def _eval(self, ctx: Context, code: str) -> Optional[discord.Message]: + """Eval the input code string & send an embed to the invoking context.""" + self.ln += 1 + + if code.startswith("exit"): + self.ln = 0 + self.env = {} + return await ctx.send("```Reset history!```") + + env = { + "message": ctx.message, + "author": ctx.message.author, + "channel": ctx.channel, + "guild": ctx.guild, + "ctx": ctx, + "self": self, + "bot": self.bot, + "inspect": inspect, + "discord": discord, + "contextlib": contextlib + } + + self.env.update(env) + + # Ignore this code, it works + code_ = """ +async def func(): # (None,) -> Any + try: + with contextlib.redirect_stdout(self.stdout): +{0} + if '_' in locals(): + if inspect.isawaitable(_): + _ = await _ + return _ + finally: + self.env.update(locals()) +""".format(textwrap.indent(code, ' ')) + + try: + exec(code_, self.env) # noqa: B102,S102 + func = self.env['func'] + res = await func() + + except Exception: + res = traceback.format_exc() + + out, embed = self._format(code, res) + await ctx.send(f"```py\n{out}```", embed=embed) + + @group(name='internal', aliases=('int',)) + @with_role(Roles.owners, Roles.admins) + async def internal_group(self, ctx: Context) -> None: + """Internal commands. Top secret!""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @internal_group.command(name='eval', aliases=('e',)) + @with_role(Roles.admins, Roles.owners) + async def eval(self, ctx: Context, *, code: str) -> None: + """Run eval in a REPL-like format.""" + code = code.strip("`") + if re.match('py(thon)?\n', code): + code = "\n".join(code.split("\n")[1:]) + + if not re.search( # Check if it's an expression + r"^(return|import|for|while|def|class|" + r"from|exit|[a-zA-Z0-9]+\s*=)", code, re.M) and len( + code.split("\n")) == 1: + code = "_ = " + code + + await self._eval(ctx, code) + + +def setup(bot: Bot) -> None: + """Load the CodeEval cog.""" + bot.add_cog(CodeEval(bot)) diff --git a/bot/exts/utils/extensions.py b/bot/exts/utils/extensions.py new file mode 100644 index 000000000..671397650 --- /dev/null +++ b/bot/exts/utils/extensions.py @@ -0,0 +1,289 @@ +import functools +import importlib +import inspect +import logging +import pkgutil +import typing as t +from enum import Enum + +from discord import Colour, Embed +from discord.ext import commands +from discord.ext.commands import Context, group + +from bot import exts +from bot.bot import Bot +from bot.constants import Emojis, MODERATION_ROLES, Roles, URLs +from bot.pagination import LinePaginator +from bot.utils.checks import with_role_check + +log = logging.getLogger(__name__) + + +def walk_extensions() -> t.Iterator[str]: + """Yield extension names from the bot.exts subpackage.""" + + def on_error(name: str) -> t.NoReturn: + raise ImportError(name=name) # pragma: no cover + + for module in pkgutil.walk_packages(exts.__path__, f"{exts.__name__}.", onerror=on_error): + if module.name.rsplit(".", maxsplit=1)[-1].startswith("_"): + # Ignore module/package names starting with an underscore. + continue + + if module.ispkg: + imported = importlib.import_module(module.name) + if not inspect.isfunction(getattr(imported, "setup", None)): + # If it lacks a setup function, it's not an extension. + continue + + yield module.name + + +UNLOAD_BLACKLIST = {f"{exts.__name__}.utils.extensions", f"{exts.__name__}.moderation.modlog"} +EXTENSIONS = frozenset(walk_extensions()) +BASE_PATH_LEN = len(exts.__name__.split(".")) + + +class Action(Enum): + """Represents an action to perform on an extension.""" + + # Need to be partial otherwise they are considered to be function definitions. + LOAD = functools.partial(Bot.load_extension) + UNLOAD = functools.partial(Bot.unload_extension) + RELOAD = functools.partial(Bot.reload_extension) + + +class Extension(commands.Converter): + """ + Fully qualify the name of an extension and ensure it exists. + + The * and ** values bypass this when used with the reload command. + """ + + async def convert(self, ctx: Context, argument: str) -> str: + """Fully qualify the name of an extension and ensure it exists.""" + # Special values to reload all extensions + if argument == "*" or argument == "**": + return argument + + argument = argument.lower() + + if argument in EXTENSIONS: + return argument + elif (qualified_arg := f"{exts.__name__}.{argument}") in EXTENSIONS: + return qualified_arg + + matches = [] + for ext in EXTENSIONS: + name = ext.rsplit(".", maxsplit=1)[-1] + if argument == name: + matches.append(ext) + + if len(matches) > 1: + matches.sort() + names = "\n".join(matches) + raise commands.BadArgument( + f":x: `{argument}` is an ambiguous extension name. " + f"Please use one of the following fully-qualified names.```\n{names}```" + ) + elif matches: + return matches[0] + else: + raise commands.BadArgument(f":x: Could not find the extension `{argument}`.") + + +class Extensions(commands.Cog): + """Extension management commands.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @group(name="extensions", aliases=("ext", "exts", "c", "cogs"), invoke_without_command=True) + async def extensions_group(self, ctx: Context) -> None: + """Load, unload, reload, and list loaded extensions.""" + await ctx.send_help(ctx.command) + + @extensions_group.command(name="load", aliases=("l",)) + async def load_command(self, ctx: Context, *extensions: Extension) -> None: + r""" + Load extensions given their fully qualified or unqualified names. + + If '\*' or '\*\*' is given as the name, all unloaded extensions will be loaded. + """ # noqa: W605 + if not extensions: + await ctx.send_help(ctx.command) + return + + if "*" in extensions or "**" in extensions: + extensions = set(EXTENSIONS) - set(self.bot.extensions.keys()) + + msg = self.batch_manage(Action.LOAD, *extensions) + await ctx.send(msg) + + @extensions_group.command(name="unload", aliases=("ul",)) + async def unload_command(self, ctx: Context, *extensions: Extension) -> None: + r""" + Unload currently loaded extensions given their fully qualified or unqualified names. + + If '\*' or '\*\*' is given as the name, all loaded extensions will be unloaded. + """ # noqa: W605 + if not extensions: + await ctx.send_help(ctx.command) + return + + blacklisted = "\n".join(UNLOAD_BLACKLIST & set(extensions)) + + if blacklisted: + msg = f":x: The following extension(s) may not be unloaded:```{blacklisted}```" + else: + if "*" in extensions or "**" in extensions: + extensions = set(self.bot.extensions.keys()) - UNLOAD_BLACKLIST + + msg = self.batch_manage(Action.UNLOAD, *extensions) + + await ctx.send(msg) + + @extensions_group.command(name="reload", aliases=("r",)) + async def reload_command(self, ctx: Context, *extensions: Extension) -> None: + r""" + Reload extensions given their fully qualified or unqualified names. + + If an extension fails to be reloaded, it will be rolled-back to the prior working state. + + If '\*' is given as the name, all currently loaded extensions will be reloaded. + If '\*\*' is given as the name, all extensions, including unloaded ones, will be reloaded. + """ # noqa: W605 + if not extensions: + await ctx.send_help(ctx.command) + return + + if "**" in extensions: + extensions = EXTENSIONS + elif "*" in extensions: + extensions = set(self.bot.extensions.keys()) | set(extensions) + extensions.remove("*") + + msg = self.batch_manage(Action.RELOAD, *extensions) + + await ctx.send(msg) + + @extensions_group.command(name="list", aliases=("all",)) + async def list_command(self, ctx: Context) -> None: + """ + Get a list of all extensions, including their loaded status. + + Grey indicates that the extension is unloaded. + Green indicates that the extension is currently loaded. + """ + embed = Embed(colour=Colour.blurple()) + embed.set_author( + name="Extensions List", + url=URLs.github_bot_repo, + icon_url=URLs.bot_avatar + ) + + lines = [] + categories = self.group_extension_statuses() + for category, extensions in sorted(categories.items()): + # Treat each category as a single line by concatenating everything. + # This ensures the paginator will not cut off a page in the middle of a category. + category = category.replace("_", " ").title() + extensions = "\n".join(sorted(extensions)) + lines.append(f"**{category}**\n{extensions}\n") + + log.debug(f"{ctx.author} requested a list of all cogs. Returning a paginated list.") + await LinePaginator.paginate(lines, ctx, embed, scale_to_size=700, empty=False) + + def group_extension_statuses(self) -> t.Mapping[str, str]: + """Return a mapping of extension names and statuses to their categories.""" + categories = {} + + for ext in EXTENSIONS: + if ext in self.bot.extensions: + status = Emojis.status_online + else: + status = Emojis.status_offline + + path = ext.split(".") + if len(path) > BASE_PATH_LEN + 1: + category = " - ".join(path[BASE_PATH_LEN:-1]) + else: + category = "uncategorised" + + categories.setdefault(category, []).append(f"{status} {path[-1]}") + + return categories + + def batch_manage(self, action: Action, *extensions: str) -> str: + """ + Apply an action to multiple extensions and return a message with the results. + + If only one extension is given, it is deferred to `manage()`. + """ + if len(extensions) == 1: + msg, _ = self.manage(action, extensions[0]) + return msg + + verb = action.name.lower() + failures = {} + + for extension in extensions: + _, error = self.manage(action, extension) + if error: + failures[extension] = error + + emoji = ":x:" if failures else ":ok_hand:" + msg = f"{emoji} {len(extensions) - len(failures)} / {len(extensions)} extensions {verb}ed." + + if failures: + failures = "\n".join(f"{ext}\n {err}" for ext, err in failures.items()) + msg += f"\nFailures:```{failures}```" + + log.debug(f"Batch {verb}ed extensions.") + + return msg + + def manage(self, action: Action, ext: str) -> t.Tuple[str, t.Optional[str]]: + """Apply an action to an extension and return the status message and any error message.""" + verb = action.name.lower() + error_msg = None + + try: + action.value(self.bot, ext) + except (commands.ExtensionAlreadyLoaded, commands.ExtensionNotLoaded): + if action is Action.RELOAD: + # When reloading, just load the extension if it was not loaded. + return self.manage(Action.LOAD, ext) + + msg = f":x: Extension `{ext}` is already {verb}ed." + log.debug(msg[4:]) + except Exception as e: + if hasattr(e, "original"): + e = e.original + + log.exception(f"Extension '{ext}' failed to {verb}.") + + error_msg = f"{e.__class__.__name__}: {e}" + msg = f":x: Failed to {verb} extension `{ext}`:\n```{error_msg}```" + else: + msg = f":ok_hand: Extension successfully {verb}ed: `{ext}`." + log.debug(msg[10:]) + + return msg, error_msg + + # This cannot be static (must have a __func__ attribute). + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators and core developers to invoke the commands in this cog.""" + return with_role_check(ctx, *MODERATION_ROLES, Roles.core_developers) + + # This cannot be static (must have a __func__ attribute). + async def cog_command_error(self, ctx: Context, error: Exception) -> None: + """Handle BadArgument errors locally to prevent the help command from showing.""" + if isinstance(error, commands.BadArgument): + await ctx.send(str(error)) + error.handled = True + + +def setup(bot: Bot) -> None: + """Load the Extensions cog.""" + bot.add_cog(Extensions(bot)) diff --git a/bot/exts/utils/jams.py b/bot/exts/utils/jams.py new file mode 100644 index 000000000..b3102db2f --- /dev/null +++ b/bot/exts/utils/jams.py @@ -0,0 +1,150 @@ +import logging +import typing as t + +from discord import CategoryChannel, Guild, Member, PermissionOverwrite, Role +from discord.ext import commands +from more_itertools import unique_everseen + +from bot.bot import Bot +from bot.constants import Roles +from bot.decorators import with_role + +log = logging.getLogger(__name__) + +MAX_CHANNELS = 50 +CATEGORY_NAME = "Code Jam" + + +class CodeJams(commands.Cog): + """Manages the code-jam related parts of our server.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @commands.command() + @with_role(Roles.admins) + async def createteam(self, ctx: commands.Context, team_name: str, members: commands.Greedy[Member]) -> None: + """ + Create team channels (voice and text) in the Code Jams category, assign roles, and add overwrites for the team. + + The first user passed will always be the team leader. + """ + # Ignore duplicate members + members = list(unique_everseen(members)) + + # We had a little issue during Code Jam 4 here, the greedy converter did it's job + # and ignored anything which wasn't a valid argument which left us with teams of + # two members or at some times even 1 member. This fixes that by checking that there + # are always 3 members in the members list. + if len(members) < 3: + await ctx.send( + ":no_entry_sign: One of your arguments was invalid\n" + f"There must be a minimum of 3 valid members in your team. Found: {len(members)}" + " members" + ) + return + + team_channel = await self.create_channels(ctx.guild, team_name, members) + await self.add_roles(ctx.guild, members) + + await ctx.send( + f":ok_hand: Team created: {team_channel}\n" + f"**Team Leader:** {members[0].mention}\n" + f"**Team Members:** {' '.join(member.mention for member in members[1:])}" + ) + + async def get_category(self, guild: Guild) -> CategoryChannel: + """ + Return a code jam category. + + If all categories are full or none exist, create a new category. + """ + for category in guild.categories: + # Need 2 available spaces: one for the text channel and one for voice. + if category.name == CATEGORY_NAME and MAX_CHANNELS - len(category.channels) >= 2: + return category + + return await self.create_category(guild) + + @staticmethod + async def create_category(guild: Guild) -> CategoryChannel: + """Create a new code jam category and return it.""" + log.info("Creating a new code jam category.") + + category_overwrites = { + guild.default_role: PermissionOverwrite(read_messages=False), + guild.me: PermissionOverwrite(read_messages=True) + } + + return await guild.create_category_channel( + CATEGORY_NAME, + overwrites=category_overwrites, + reason="It's code jam time!" + ) + + @staticmethod + def get_overwrites(members: t.List[Member], guild: Guild) -> t.Dict[t.Union[Member, Role], PermissionOverwrite]: + """Get code jam team channels permission overwrites.""" + # First member is always the team leader + team_channel_overwrites = { + members[0]: PermissionOverwrite( + manage_messages=True, + read_messages=True, + manage_webhooks=True, + connect=True + ), + guild.default_role: PermissionOverwrite(read_messages=False, connect=False), + guild.get_role(Roles.verified): PermissionOverwrite( + read_messages=False, + connect=False + ) + } + + # Rest of members should just have read_messages + for member in members[1:]: + team_channel_overwrites[member] = PermissionOverwrite( + read_messages=True, + connect=True + ) + + return team_channel_overwrites + + async def create_channels(self, guild: Guild, team_name: str, members: t.List[Member]) -> str: + """Create team text and voice channels. Return the mention for the text channel.""" + # Get permission overwrites and category + team_channel_overwrites = self.get_overwrites(members, guild) + code_jam_category = await self.get_category(guild) + + # Create a text channel for the team + team_channel = await guild.create_text_channel( + team_name, + overwrites=team_channel_overwrites, + category=code_jam_category + ) + + # Create a voice channel for the team + team_voice_name = " ".join(team_name.split("-")).title() + + await guild.create_voice_channel( + team_voice_name, + overwrites=team_channel_overwrites, + category=code_jam_category + ) + + return team_channel.mention + + @staticmethod + async def add_roles(guild: Guild, members: t.List[Member]) -> None: + """Assign team leader and jammer roles.""" + # Assign team leader role + await members[0].add_roles(guild.get_role(Roles.team_leaders)) + + # Assign rest of roles + jammer_role = guild.get_role(Roles.jammers) + for member in members: + await member.add_roles(jammer_role) + + +def setup(bot: Bot) -> None: + """Load the CodeJams cog.""" + bot.add_cog(CodeJams(bot)) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py new file mode 100644 index 000000000..670493bcf --- /dev/null +++ b/bot/exts/utils/reminders.py @@ -0,0 +1,427 @@ +import asyncio +import logging +import random +import textwrap +import typing as t +from datetime import datetime, timedelta +from operator import itemgetter + +import discord +from dateutil.parser import isoparse +from dateutil.relativedelta import relativedelta +from discord.ext.commands import Cog, Context, Greedy, group + +from bot.bot import Bot +from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, STAFF_ROLES +from bot.converters import Duration +from bot.pagination import LinePaginator +from bot.utils.checks import without_role_check +from bot.utils.messages import send_denial +from bot.utils.scheduling import Scheduler +from bot.utils.time import humanize_delta + +log = logging.getLogger(__name__) + +WHITELISTED_CHANNELS = Guild.reminder_whitelist +MAXIMUM_REMINDERS = 5 + +Mentionable = t.Union[discord.Member, discord.Role] + + +class Reminders(Cog): + """Provide in-channel reminder functionality.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.scheduler = Scheduler(self.__class__.__name__) + + self.bot.loop.create_task(self.reschedule_reminders()) + + def cog_unload(self) -> None: + """Cancel scheduled tasks.""" + self.scheduler.cancel_all() + + async def reschedule_reminders(self) -> None: + """Get all current reminders from the API and reschedule them.""" + await self.bot.wait_until_guild_available() + response = await self.bot.api_client.get( + 'bot/reminders', + params={'active': 'true'} + ) + + now = datetime.utcnow() + + for reminder in response: + is_valid, *_ = self.ensure_valid_reminder(reminder, cancel_task=False) + if not is_valid: + continue + + remind_at = isoparse(reminder['expiration']).replace(tzinfo=None) + + # If the reminder is already overdue ... + if remind_at < now: + late = relativedelta(now, remind_at) + await self.send_reminder(reminder, late) + else: + self.schedule_reminder(reminder) + + def ensure_valid_reminder( + self, + reminder: dict, + cancel_task: bool = True + ) -> t.Tuple[bool, discord.User, discord.TextChannel]: + """Ensure reminder author and channel can be fetched otherwise delete the reminder.""" + user = self.bot.get_user(reminder['author']) + channel = self.bot.get_channel(reminder['channel_id']) + is_valid = True + if not user or not channel: + is_valid = False + log.info( + f"Reminder {reminder['id']} invalid: " + f"User {reminder['author']}={user}, Channel {reminder['channel_id']}={channel}." + ) + asyncio.create_task(self._delete_reminder(reminder['id'], cancel_task)) + + return is_valid, user, channel + + @staticmethod + async def _send_confirmation( + ctx: Context, + on_success: str, + reminder_id: str, + 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 + + 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')}" + + embed.set_footer(text=footer_str) + + await ctx.send(embed=embed) + + @staticmethod + async def _check_mentions(ctx: Context, mentions: t.Iterable[Mentionable]) -> t.Tuple[bool, str]: + """ + Returns whether or not the list of mentions is allowed. + + Conditions: + - Role reminders are Mods+ + - Reminders for other users are Helpers+ + + If mentions aren't allowed, also return the type of mention(s) disallowed. + """ + if without_role_check(ctx, *STAFF_ROLES): + return False, "members/roles" + elif without_role_check(ctx, *MODERATION_ROLES): + return all(isinstance(mention, discord.Member) for mention in mentions), "roles" + else: + return True, "" + + @staticmethod + async def validate_mentions(ctx: Context, mentions: t.Iterable[Mentionable]) -> bool: + """ + Filter mentions to see if the user can mention, and sends a denial if not allowed. + + Returns whether or not the validation is successful. + """ + mentions_allowed, disallowed_mentions = await Reminders._check_mentions(ctx, mentions) + + if not mentions or mentions_allowed: + return True + else: + await send_denial(ctx, f"You can't mention other {disallowed_mentions} in your reminder!") + return False + + def get_mentionables(self, mention_ids: t.List[int]) -> t.Iterator[Mentionable]: + """Converts Role and Member ids to their corresponding objects if possible.""" + guild = self.bot.get_guild(Guild.id) + for mention_id in mention_ids: + if (mentionable := (guild.get_member(mention_id) or guild.get_role(mention_id))): + yield mentionable + + def schedule_reminder(self, reminder: dict) -> None: + """A coroutine which sends the reminder once the time is reached, and cancels the running task.""" + reminder_id = reminder["id"] + reminder_datetime = isoparse(reminder['expiration']).replace(tzinfo=None) + + async def _remind() -> None: + await self.send_reminder(reminder) + + log.debug(f"Deleting reminder {reminder_id} (the user has been reminded).") + await self._delete_reminder(reminder_id) + + self.scheduler.schedule_at(reminder_datetime, reminder_id, _remind()) + + async def _delete_reminder(self, reminder_id: str, cancel_task: bool = True) -> None: + """Delete a reminder from the database, given its ID, and cancel the running task.""" + await self.bot.api_client.delete('bot/reminders/' + str(reminder_id)) + + if cancel_task: + # Now we can remove it from the schedule list + self.scheduler.cancel(reminder_id) + + async def _edit_reminder(self, reminder_id: int, payload: dict) -> dict: + """ + Edits a reminder in the database given the ID and payload. + + Returns the edited reminder. + """ + # Send the request to update the reminder in the database + reminder = await self.bot.api_client.patch( + 'bot/reminders/' + str(reminder_id), + json=payload + ) + return reminder + + async def _reschedule_reminder(self, reminder: dict) -> None: + """Reschedule a reminder object.""" + log.trace(f"Cancelling old task #{reminder['id']}") + self.scheduler.cancel(reminder["id"]) + + log.trace(f"Scheduling new task #{reminder['id']}") + self.schedule_reminder(reminder) + + async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: + """Send the reminder.""" + is_valid, user, channel = self.ensure_valid_reminder(reminder) + if not is_valid: + return + + embed = discord.Embed() + embed.colour = discord.Colour.blurple() + embed.set_author( + icon_url=Icons.remind_blurple, + name="It has arrived!" + ) + + embed.description = f"Here's your reminder: `{reminder['content']}`." + + if reminder.get("jump_url"): # keep backward compatibility + embed.description += f"\n[Jump back to when you created the reminder]({reminder['jump_url']})" + + if late: + embed.colour = discord.Colour.red() + embed.set_author( + icon_url=Icons.remind_red, + name=f"Sorry it arrived {humanize_delta(late, max_units=2)} late!" + ) + + additional_mentions = ' '.join( + mentionable.mention for mentionable in self.get_mentionables(reminder["mentions"]) + ) + + await channel.send( + content=f"{user.mention} {additional_mentions}", + embed=embed + ) + await self._delete_reminder(reminder["id"]) + + @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True) + async def remind_group( + self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str + ) -> None: + """Commands for managing your reminders.""" + await ctx.invoke(self.new_reminder, mentions=mentions, expiration=expiration, content=content) + + @remind_group.command(name="new", aliases=("add", "create")) + async def new_reminder( + self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str + ) -> None: + """ + Set yourself a simple reminder. + + Expiration is parsed per: http://strftime.org/ + """ + # If the user is not staff, we need to verify whether or not to make a reminder at all. + if without_role_check(ctx, *STAFF_ROLES): + + # If they don't have permission to set a reminder in this channel + if ctx.channel.id not in WHITELISTED_CHANNELS: + await send_denial(ctx, "Sorry, you can't do that here!") + return + + # Get their current active reminders + active_reminders = await self.bot.api_client.get( + 'bot/reminders', + params={ + 'author__id': str(ctx.author.id) + } + ) + + # Let's limit this, so we don't get 10 000 + # reminders from kip or something like that :P + if len(active_reminders) > MAXIMUM_REMINDERS: + await send_denial(ctx, "You have too many active reminders!") + return + + # Remove duplicate mentions + mentions = set(mentions) + mentions.discard(ctx.author) + + # Filter mentions to see if the user can mention members/roles + if not await self.validate_mentions(ctx, mentions): + return + + mention_ids = [mention.id for mention in mentions] + + # Now we can attempt to actually set the reminder. + reminder = await self.bot.api_client.post( + 'bot/reminders', + json={ + 'author': ctx.author.id, + 'channel_id': ctx.message.channel.id, + 'jump_url': ctx.message.jump_url, + 'content': content, + 'expiration': expiration.isoformat(), + 'mentions': mention_ids, + } + ) + + now = datetime.utcnow() - timedelta(seconds=1) + humanized_delta = humanize_delta(relativedelta(expiration, now)) + mention_string = ( + f"Your reminder will arrive in {humanized_delta} " + f"and will mention {len(mentions)} other(s)!" + ) + + # Confirm to the user that it worked. + await self._send_confirmation( + ctx, + on_success=mention_string, + reminder_id=reminder["id"], + delivery_dt=expiration, + ) + + self.schedule_reminder(reminder) + + @remind_group.command(name="list") + async def list_reminders(self, ctx: Context) -> None: + """View a paginated embed of all reminders for your user.""" + # Get all the user's reminders from the database. + data = await self.bot.api_client.get( + 'bot/reminders', + params={'author__id': str(ctx.author.id)} + ) + + now = datetime.utcnow() + + # Make a list of tuples so it can be sorted by time. + reminders = sorted( + ( + (rem['content'], rem['expiration'], rem['id'], rem['mentions']) + for rem in data + ), + key=itemgetter(1) + ) + + lines = [] + + for content, remind_at, id_, mentions in reminders: + # Parse and humanize the time, make it pretty :D + remind_datetime = isoparse(remind_at).replace(tzinfo=None) + time = humanize_delta(relativedelta(remind_datetime, now)) + + mentions = ", ".join( + # Both Role and User objects have the `name` attribute + mention.name for mention in self.get_mentionables(mentions) + ) + mention_string = f"\n**Mentions:** {mentions}" if mentions else "" + + text = textwrap.dedent(f""" + **Reminder #{id_}:** *expires in {time}* (ID: {id_}){mention_string} + {content} + """).strip() + + lines.append(text) + + embed = discord.Embed() + embed.colour = discord.Colour.blurple() + embed.title = f"Reminders for {ctx.author}" + + # Remind the user that they have no reminders :^) + if not lines: + embed.description = "No active reminders could be found." + await ctx.send(embed=embed) + return + + # Construct the embed and paginate it. + embed.colour = discord.Colour.blurple() + + await LinePaginator.paginate( + lines, + ctx, embed, + max_lines=3, + empty=True + ) + + @remind_group.group(name="edit", aliases=("change", "modify"), invoke_without_command=True) + async def edit_reminder_group(self, ctx: Context) -> None: + """Commands for modifying your current reminders.""" + await ctx.send_help(ctx.command) + + @edit_reminder_group.command(name="duration", aliases=("time",)) + async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: Duration) -> None: + """ + Edit one of your reminder's expiration. + + Expiration is parsed per: http://strftime.org/ + """ + await self.edit_reminder(ctx, id_, {'expiration': expiration.isoformat()}) + + @edit_reminder_group.command(name="content", aliases=("reason",)) + async def edit_reminder_content(self, ctx: Context, id_: int, *, content: str) -> None: + """Edit one of your reminder's content.""" + await self.edit_reminder(ctx, id_, {"content": content}) + + @edit_reminder_group.command(name="mentions", aliases=("pings",)) + async def edit_reminder_mentions(self, ctx: Context, id_: int, mentions: Greedy[Mentionable]) -> None: + """Edit one of your reminder's mentions.""" + # Remove duplicate mentions + mentions = set(mentions) + mentions.discard(ctx.author) + + # Filter mentions to see if the user can mention members/roles + if not await self.validate_mentions(ctx, mentions): + return + + mention_ids = [mention.id for mention in mentions] + await self.edit_reminder(ctx, id_, {"mentions": mention_ids}) + + async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: + """Edits a reminder with the given payload, then sends a confirmation message.""" + reminder = await self._edit_reminder(id_, payload) + + # Parse the reminder expiration back into a datetime + expiration = isoparse(reminder["expiration"]).replace(tzinfo=None) + + # Send a confirmation message to the channel + await self._send_confirmation( + ctx, + on_success="That reminder has been edited successfully!", + reminder_id=id_, + delivery_dt=expiration, + ) + await self._reschedule_reminder(reminder) + + @remind_group.command("delete", aliases=("remove", "cancel")) + async def delete_reminder(self, ctx: Context, id_: int) -> None: + """Delete one of your active reminders.""" + await self._delete_reminder(id_) + await self._send_confirmation( + ctx, + on_success="That reminder has been deleted successfully!", + reminder_id=id_, + delivery_dt=None, + ) + + +def setup(bot: Bot) -> None: + """Load the Reminders cog.""" + bot.add_cog(Reminders(bot)) diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py new file mode 100644 index 000000000..52c8b6f88 --- /dev/null +++ b/bot/exts/utils/snekbox.py @@ -0,0 +1,349 @@ +import asyncio +import contextlib +import datetime +import logging +import re +import textwrap +from functools import partial +from signal import Signals +from typing import Optional, Tuple + +from discord import HTTPException, Message, NotFound, Reaction, User +from discord.ext.commands import Cog, Context, command, guild_only + +from bot.bot import Bot +from bot.constants import Categories, Channels, Roles, URLs +from bot.decorators import in_whitelist +from bot.utils.messages import wait_for_deletion + +log = logging.getLogger(__name__) + +ESCAPE_REGEX = re.compile("[`\u202E\u200B]{3,}") +FORMATTED_CODE_REGEX = re.compile( + r"^\s*" # any leading whitespace from the beginning of the string + r"(?P(?P```)|``?)" # code delimiter: 1-3 backticks; (?P=block) only matches if it's a block + r"(?(block)(?:(?P[a-z]+)\n)?)" # if we're in a block, match optional language (only letters plus newline) + r"(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code + r"(?P.*?)" # extract all code inside the markup + r"\s*" # any more whitespace before the end of the code markup + r"(?P=delim)" # match the exact same delimiter from the start again + r"\s*$", # any trailing whitespace until the end of the string + re.DOTALL | re.IGNORECASE # "." also matches newlines, case insensitive +) +RAW_CODE_REGEX = re.compile( + r"^(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code + r"(?P.*?)" # extract all the rest as code + r"\s*$", # any trailing whitespace until the end of the string + re.DOTALL # "." also matches newlines +) + +MAX_PASTE_LEN = 1000 + +# `!eval` command whitelists +EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric) +EVAL_CATEGORIES = (Categories.help_available, Categories.help_in_use) +EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners) + +SIGKILL = 9 + +REEVAL_EMOJI = '\U0001f501' # :repeat: +REEVAL_TIMEOUT = 30 + + +class Snekbox(Cog): + """Safe evaluation of Python code using Snekbox.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.jobs = {} + + async def post_eval(self, code: str) -> dict: + """Send a POST request to the Snekbox API to evaluate code and return the results.""" + url = URLs.snekbox_eval_api + data = {"input": code} + async with self.bot.http_session.post(url, json=data, raise_for_status=True) as resp: + return await resp.json() + + async def upload_output(self, output: str) -> Optional[str]: + """Upload the eval output to a paste service and return a URL to it if successful.""" + log.trace("Uploading full output to paste service...") + + if len(output) > MAX_PASTE_LEN: + log.info("Full output is too long to upload") + return "too long to upload" + + url = URLs.paste_service.format(key="documents") + try: + async with self.bot.http_session.post(url, data=output, raise_for_status=True) as resp: + data = await resp.json() + + if "key" in data: + return URLs.paste_service.format(key=data["key"]) + except Exception: + # 400 (Bad Request) means there are too many characters + log.exception("Failed to upload full output to paste service!") + + @staticmethod + def prepare_input(code: str) -> str: + """Extract code from the Markdown, format it, and insert it into the code template.""" + match = FORMATTED_CODE_REGEX.fullmatch(code) + if match: + code, block, lang, delim = match.group("code", "block", "lang", "delim") + code = textwrap.dedent(code) + if block: + info = (f"'{lang}' highlighted" if lang else "plain") + " code block" + else: + info = f"{delim}-enclosed inline code" + log.trace(f"Extracted {info} for evaluation:\n{code}") + else: + code = textwrap.dedent(RAW_CODE_REGEX.fullmatch(code).group("code")) + log.trace( + f"Eval message contains unformatted or badly formatted code, " + f"stripping whitespace only:\n{code}" + ) + + return code + + @staticmethod + def get_results_message(results: dict) -> Tuple[str, str]: + """Return a user-friendly message and error corresponding to the process's return code.""" + stdout, returncode = results["stdout"], results["returncode"] + msg = f"Your eval job has completed with return code {returncode}" + error = "" + + if returncode is None: + msg = "Your eval job has failed" + error = stdout.strip() + elif returncode == 128 + SIGKILL: + msg = "Your eval job timed out or ran out of memory" + elif returncode == 255: + msg = "Your eval job has failed" + error = "A fatal NsJail error occurred" + else: + # Try to append signal's name if one exists + try: + name = Signals(returncode - 128).name + msg = f"{msg} ({name})" + except ValueError: + pass + + return msg, error + + @staticmethod + def get_status_emoji(results: dict) -> str: + """Return an emoji corresponding to the status code or lack of output in result.""" + if not results["stdout"].strip(): # No output + return ":warning:" + elif results["returncode"] == 0: # No error + return ":white_check_mark:" + else: # Exception + return ":x:" + + async def format_output(self, output: str) -> Tuple[str, Optional[str]]: + """ + Format the output and return a tuple of the formatted output and a URL to the full output. + + Prepend each line with a line number. Truncate if there are over 10 lines or 1000 characters + and upload the full output to a paste service. + """ + log.trace("Formatting output...") + + output = output.rstrip("\n") + original_output = output # To be uploaded to a pasting service if needed + paste_link = None + + if "<@" in output: + output = output.replace("<@", "<@\u200B") # Zero-width space + + if " 0: + output = [f"{i:03d} | {line}" for i, line in enumerate(output.split('\n'), 1)] + output = output[:11] # Limiting to only 11 lines + output = "\n".join(output) + + if lines > 10: + truncated = True + if len(output) >= 1000: + output = f"{output[:1000]}\n... (truncated - too long, too many lines)" + else: + output = f"{output}\n... (truncated - too many lines)" + elif len(output) >= 1000: + truncated = True + output = f"{output[:1000]}\n... (truncated - too long)" + + if truncated: + paste_link = await self.upload_output(original_output) + + output = output or "[No output]" + + return output, paste_link + + async def send_eval(self, ctx: Context, code: str) -> Message: + """ + Evaluate code, format it, and send the output to the corresponding channel. + + Return the bot response. + """ + async with ctx.typing(): + results = await self.post_eval(code) + msg, error = self.get_results_message(results) + + if error: + output, paste_link = error, None + else: + output, paste_link = await self.format_output(results["stdout"]) + + icon = self.get_status_emoji(results) + msg = f"{ctx.author.mention} {icon} {msg}.\n\n```\n{output}\n```" + if paste_link: + msg = f"{msg}\nFull output: {paste_link}" + + # Collect stats of eval fails + successes + if icon == ":x:": + self.bot.stats.incr("snekbox.python.fail") + else: + self.bot.stats.incr("snekbox.python.success") + + filter_cog = self.bot.get_cog("Filtering") + filter_triggered = False + if filter_cog: + filter_triggered = await filter_cog.filter_eval(msg, ctx.message) + if filter_triggered: + response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.") + else: + response = await ctx.send(msg) + self.bot.loop.create_task( + wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot) + ) + + log.info(f"{ctx.author}'s job had a return code of {results['returncode']}") + return response + + async def continue_eval(self, ctx: Context, response: Message) -> Optional[str]: + """ + Check if the eval session should continue. + + Return the new code to evaluate or None if the eval session should be terminated. + """ + _predicate_eval_message_edit = partial(predicate_eval_message_edit, ctx) + _predicate_emoji_reaction = partial(predicate_eval_emoji_reaction, ctx) + + with contextlib.suppress(NotFound): + try: + _, new_message = await self.bot.wait_for( + 'message_edit', + check=_predicate_eval_message_edit, + timeout=REEVAL_TIMEOUT + ) + await ctx.message.add_reaction(REEVAL_EMOJI) + await self.bot.wait_for( + 'reaction_add', + check=_predicate_emoji_reaction, + timeout=10 + ) + + code = await self.get_code(new_message) + await ctx.message.clear_reactions() + with contextlib.suppress(HTTPException): + await response.delete() + + except asyncio.TimeoutError: + await ctx.message.clear_reactions() + return None + + return code + + async def get_code(self, message: Message) -> Optional[str]: + """ + Return the code from `message` to be evaluated. + + If the message is an invocation of the eval command, return the first argument or None if it + doesn't exist. Otherwise, return the full content of the message. + """ + log.trace(f"Getting context for message {message.id}.") + new_ctx = await self.bot.get_context(message) + + if new_ctx.command is self.eval_command: + log.trace(f"Message {message.id} invokes eval command.") + split = message.content.split(maxsplit=1) + code = split[1] if len(split) > 1 else None + else: + log.trace(f"Message {message.id} does not invoke eval command.") + code = message.content + + return code + + @command(name="eval", aliases=("e",)) + @guild_only() + @in_whitelist(channels=EVAL_CHANNELS, categories=EVAL_CATEGORIES, roles=EVAL_ROLES) + async def eval_command(self, ctx: Context, *, code: str = None) -> None: + """ + Run Python code and get the results. + + This command supports multiple lines of code, including code wrapped inside a formatted code + block. Code can be re-evaluated by editing the original message within 10 seconds and + clicking the reaction that subsequently appears. + + We've done our best to make this sandboxed, but do let us know if you manage to find an + issue with it! + """ + if ctx.author.id in self.jobs: + await ctx.send( + f"{ctx.author.mention} You've already got a job running - " + "please wait for it to finish!" + ) + return + + if not code: # None or empty string + await ctx.send_help(ctx.command) + return + + if Roles.helpers in (role.id for role in ctx.author.roles): + self.bot.stats.incr("snekbox_usages.roles.helpers") + else: + self.bot.stats.incr("snekbox_usages.roles.developers") + + if ctx.channel.category_id == Categories.help_in_use: + self.bot.stats.incr("snekbox_usages.channels.help") + elif ctx.channel.id == Channels.bot_commands: + self.bot.stats.incr("snekbox_usages.channels.bot_commands") + else: + self.bot.stats.incr("snekbox_usages.channels.topical") + + log.info(f"Received code from {ctx.author} for evaluation:\n{code}") + + while True: + self.jobs[ctx.author.id] = datetime.datetime.now() + code = self.prepare_input(code) + try: + response = await self.send_eval(ctx, code) + finally: + del self.jobs[ctx.author.id] + + code = await self.continue_eval(ctx, response) + if not code: + break + log.info(f"Re-evaluating code from message {ctx.message.id}:\n{code}") + + +def predicate_eval_message_edit(ctx: Context, old_msg: Message, new_msg: Message) -> bool: + """Return True if the edited message is the context message and the content was indeed modified.""" + return new_msg.id == ctx.message.id and old_msg.content != new_msg.content + + +def predicate_eval_emoji_reaction(ctx: Context, reaction: Reaction, user: User) -> bool: + """Return True if the reaction REEVAL_EMOJI was added by the context message author on this message.""" + return reaction.message.id == ctx.message.id and user.id == ctx.author.id and str(reaction) == REEVAL_EMOJI + + +def setup(bot: Bot) -> None: + """Load the Snekbox cog.""" + bot.add_cog(Snekbox(bot)) diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py new file mode 100644 index 000000000..d96abbd5a --- /dev/null +++ b/bot/exts/utils/utils.py @@ -0,0 +1,265 @@ +import difflib +import logging +import re +import unicodedata +from email.parser import HeaderParser +from io import StringIO +from typing import Tuple, Union + +from discord import Colour, Embed, utils +from discord.ext.commands import BadArgument, Cog, Context, clean_content, command + +from bot.bot import Bot +from bot.constants import Channels, MODERATION_ROLES, STAFF_ROLES +from bot.decorators import in_whitelist, with_role +from bot.pagination import LinePaginator +from bot.utils import messages + +log = logging.getLogger(__name__) + +ZEN_OF_PYTHON = """\ +Beautiful is better than ugly. +Explicit is better than implicit. +Simple is better than complex. +Complex is better than complicated. +Flat is better than nested. +Sparse is better than dense. +Readability counts. +Special cases aren't special enough to break the rules. +Although practicality beats purity. +Errors should never pass silently. +Unless explicitly silenced. +In the face of ambiguity, refuse the temptation to guess. +There should be one-- and preferably only one --obvious way to do it. +Although that way may not be obvious at first unless you're Dutch. +Now is better than never. +Although never is often better than *right* now. +If the implementation is hard to explain, it's a bad idea. +If the implementation is easy to explain, it may be a good idea. +Namespaces are one honking great idea -- let's do more of those! +""" + +ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png" + + +class Utils(Cog): + """A selection of utilities which don't have a clear category.""" + + def __init__(self, bot: Bot): + self.bot = bot + + self.base_pep_url = "http://www.python.org/dev/peps/pep-" + self.base_github_pep_url = "https://raw.githubusercontent.com/python/peps/master/pep-" + + @command(name='pep', aliases=('get_pep', 'p')) + async def pep_command(self, ctx: Context, pep_number: str) -> None: + """Fetches information about a PEP and sends it to the channel.""" + if pep_number.isdigit(): + pep_number = int(pep_number) + else: + await ctx.send_help(ctx.command) + return + + # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs. + if pep_number == 0: + return await self.send_pep_zero(ctx) + + possible_extensions = ['.txt', '.rst'] + found_pep = False + for extension in possible_extensions: + # Attempt to fetch the PEP + pep_url = f"{self.base_github_pep_url}{pep_number:04}{extension}" + log.trace(f"Requesting PEP {pep_number} with {pep_url}") + response = await self.bot.http_session.get(pep_url) + + if response.status == 200: + log.trace("PEP found") + found_pep = True + + pep_content = await response.text() + + # Taken from https://github.com/python/peps/blob/master/pep0/pep.py#L179 + pep_header = HeaderParser().parse(StringIO(pep_content)) + + # Assemble the embed + pep_embed = Embed( + title=f"**PEP {pep_number} - {pep_header['Title']}**", + description=f"[Link]({self.base_pep_url}{pep_number:04})", + ) + + pep_embed.set_thumbnail(url=ICON_URL) + + # Add the interesting information + fields_to_check = ("Status", "Python-Version", "Created", "Type") + for field in fields_to_check: + # Check for a PEP metadata field that is present but has an empty value + # embed field values can't contain an empty string + if pep_header.get(field, ""): + pep_embed.add_field(name=field, value=pep_header[field]) + + elif response.status != 404: + # any response except 200 and 404 is expected + found_pep = True # actually not, but it's easier to display this way + log.trace(f"The user requested PEP {pep_number}, but the response had an unexpected status code: " + f"{response.status}.\n{response.text}") + + error_message = "Unexpected HTTP error during PEP search. Please let us know." + pep_embed = Embed(title="Unexpected error", description=error_message) + pep_embed.colour = Colour.red() + break + + if not found_pep: + log.trace("PEP was not found") + not_found = f"PEP {pep_number} does not exist." + pep_embed = Embed(title="PEP not found", description=not_found) + pep_embed.colour = Colour.red() + + await ctx.message.channel.send(embed=pep_embed) + + @command() + @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES) + async def charinfo(self, ctx: Context, *, characters: str) -> None: + """Shows you information on up to 50 unicode characters.""" + match = re.match(r"<(a?):(\w+):(\d+)>", characters) + if match: + return await messages.send_denial( + ctx, + "**Non-Character Detected**\n" + "Only unicode characters can be processed, but a custom Discord emoji " + "was found. Please remove it and try again." + ) + + if len(characters) > 50: + return await messages.send_denial(ctx, f"Too many characters ({len(characters)}/50)") + + def get_info(char: str) -> Tuple[str, str]: + digit = f"{ord(char):x}" + if len(digit) <= 4: + u_code = f"\\u{digit:>04}" + else: + u_code = f"\\U{digit:>08}" + url = f"https://www.compart.com/en/unicode/U+{digit:>04}" + name = f"[{unicodedata.name(char, '')}]({url})" + info = f"`{u_code.ljust(10)}`: {name} - {utils.escape_markdown(char)}" + return info, u_code + + char_list, raw_list = zip(*(get_info(c) for c in characters)) + embed = Embed().set_author(name="Character Info") + + if len(characters) > 1: + # Maximum length possible is 502 out of 1024, so there's no need to truncate. + embed.add_field(name='Full Raw Text', value=f"`{''.join(raw_list)}`", inline=False) + + await LinePaginator.paginate(char_list, ctx, embed, max_lines=10, max_size=2000, empty=False) + + @command() + async def zen(self, ctx: Context, *, search_value: Union[int, str, None] = None) -> None: + """ + Show the Zen of Python. + + Without any arguments, the full Zen will be produced. + If an integer is provided, the line with that index will be produced. + If a string is provided, the line which matches best will be produced. + """ + embed = Embed( + colour=Colour.blurple(), + title="The Zen of Python", + description=ZEN_OF_PYTHON + ) + + if search_value is None: + embed.title += ", by Tim Peters" + await ctx.send(embed=embed) + return + + zen_lines = ZEN_OF_PYTHON.splitlines() + + # handle if it's an index int + if isinstance(search_value, int): + upper_bound = len(zen_lines) - 1 + lower_bound = -1 * upper_bound + if not (lower_bound <= search_value <= upper_bound): + raise BadArgument(f"Please provide an index between {lower_bound} and {upper_bound}.") + + embed.title += f" (line {search_value % len(zen_lines)}):" + embed.description = zen_lines[search_value] + await ctx.send(embed=embed) + return + + # Try to handle first exact word due difflib.SequenceMatched may use some other similar word instead + # exact word. + for i, line in enumerate(zen_lines): + for word in line.split(): + if word.lower() == search_value.lower(): + embed.title += f" (line {i}):" + embed.description = line + await ctx.send(embed=embed) + return + + # handle if it's a search string and not exact word + matcher = difflib.SequenceMatcher(None, search_value.lower()) + + best_match = "" + match_index = 0 + best_ratio = 0 + + for index, line in enumerate(zen_lines): + matcher.set_seq2(line.lower()) + + # the match ratio needs to be adjusted because, naturally, + # longer lines will have worse ratios than shorter lines when + # fuzzy searching for keywords. this seems to work okay. + adjusted_ratio = (len(line) - 5) ** 0.5 * matcher.ratio() + + if adjusted_ratio > best_ratio: + best_ratio = adjusted_ratio + best_match = line + match_index = index + + if not best_match: + raise BadArgument("I didn't get a match! Please try again with a different search term.") + + embed.title += f" (line {match_index}):" + embed.description = best_match + await ctx.send(embed=embed) + + @command(aliases=("poll",)) + @with_role(*MODERATION_ROLES) + async def vote(self, ctx: Context, title: clean_content(fix_channel_mentions=True), *options: str) -> None: + """ + Build a quick voting poll with matching reactions with the provided options. + + A maximum of 20 options can be provided, as Discord supports a max of 20 + reactions on a single message. + """ + if len(title) > 256: + raise BadArgument("The title cannot be longer than 256 characters.") + if len(options) < 2: + raise BadArgument("Please provide at least 2 options.") + if len(options) > 20: + raise BadArgument("I can only handle 20 options!") + + codepoint_start = 127462 # represents "regional_indicator_a" unicode value + options = {chr(i): f"{chr(i)} - {v}" for i, v in enumerate(options, start=codepoint_start)} + embed = Embed(title=title, description="\n".join(options.values())) + message = await ctx.send(embed=embed) + for reaction in options: + await message.add_reaction(reaction) + + async def send_pep_zero(self, ctx: Context) -> None: + """Send information about PEP 0.""" + pep_embed = Embed( + title="**PEP 0 - Index of Python Enhancement Proposals (PEPs)**", + description="[Link](https://www.python.org/dev/peps/)" + ) + pep_embed.set_thumbnail(url=ICON_URL) + pep_embed.add_field(name="Status", value="Active") + pep_embed.add_field(name="Created", value="13-Jul-2000") + pep_embed.add_field(name="Type", value="Informational") + + await ctx.send(embed=pep_embed) + + +def setup(bot: Bot) -> None: + """Load the Utils cog.""" + bot.add_cog(Utils(bot)) diff --git a/tests/bot/cogs/__init__.py b/tests/bot/cogs/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/bot/cogs/backend/__init__.py b/tests/bot/cogs/backend/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/bot/cogs/backend/sync/__init__.py b/tests/bot/cogs/backend/sync/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/bot/cogs/backend/sync/test_base.py b/tests/bot/cogs/backend/sync/test_base.py deleted file mode 100644 index 3009aacb6..000000000 --- a/tests/bot/cogs/backend/sync/test_base.py +++ /dev/null @@ -1,404 +0,0 @@ -import asyncio -import unittest -from unittest import mock - -import discord - -from bot import constants -from bot.api import ResponseCodeError -from bot.cogs.backend.sync._syncers import Syncer, _Diff -from tests import helpers - - -class TestSyncer(Syncer): - """Syncer subclass with mocks for abstract methods for testing purposes.""" - - name = "test" - _get_diff = mock.AsyncMock() - _sync = mock.AsyncMock() - - -class SyncerBaseTests(unittest.TestCase): - """Tests for the syncer base class.""" - - def setUp(self): - self.bot = helpers.MockBot() - - def test_instantiation_fails_without_abstract_methods(self): - """The class must have abstract methods implemented.""" - with self.assertRaisesRegex(TypeError, "Can't instantiate abstract class"): - Syncer(self.bot) - - -class SyncerSendPromptTests(unittest.IsolatedAsyncioTestCase): - """Tests for sending the sync confirmation prompt.""" - - def setUp(self): - self.bot = helpers.MockBot() - self.syncer = TestSyncer(self.bot) - - def mock_get_channel(self): - """Fixture to return a mock channel and message for when `get_channel` is used.""" - self.bot.reset_mock() - - mock_channel = helpers.MockTextChannel() - mock_message = helpers.MockMessage() - - mock_channel.send.return_value = mock_message - self.bot.get_channel.return_value = mock_channel - - return mock_channel, mock_message - - def mock_fetch_channel(self): - """Fixture to return a mock channel and message for when `fetch_channel` is used.""" - self.bot.reset_mock() - - mock_channel = helpers.MockTextChannel() - mock_message = helpers.MockMessage() - - self.bot.get_channel.return_value = None - mock_channel.send.return_value = mock_message - self.bot.fetch_channel.return_value = mock_channel - - return mock_channel, mock_message - - async def test_send_prompt_edits_and_returns_message(self): - """The given message should be edited to display the prompt and then should be returned.""" - msg = helpers.MockMessage() - ret_val = await self.syncer._send_prompt(msg) - - msg.edit.assert_called_once() - self.assertIn("content", msg.edit.call_args[1]) - self.assertEqual(ret_val, msg) - - async def test_send_prompt_gets_dev_core_channel(self): - """The dev-core channel should be retrieved if an extant message isn't given.""" - subtests = ( - (self.bot.get_channel, self.mock_get_channel), - (self.bot.fetch_channel, self.mock_fetch_channel), - ) - - for method, mock_ in subtests: - with self.subTest(method=method, msg=mock_.__name__): - mock_() - await self.syncer._send_prompt() - - method.assert_called_once_with(constants.Channels.dev_core) - - async def test_send_prompt_returns_none_if_channel_fetch_fails(self): - """None should be returned if there's an HTTPException when fetching the channel.""" - self.bot.get_channel.return_value = None - self.bot.fetch_channel.side_effect = discord.HTTPException(mock.MagicMock(), "test error!") - - ret_val = await self.syncer._send_prompt() - - self.assertIsNone(ret_val) - - async def test_send_prompt_sends_and_returns_new_message_if_not_given(self): - """A new message mentioning core devs should be sent and returned if message isn't given.""" - for mock_ in (self.mock_get_channel, self.mock_fetch_channel): - with self.subTest(msg=mock_.__name__): - mock_channel, mock_message = mock_() - ret_val = await self.syncer._send_prompt() - - mock_channel.send.assert_called_once() - self.assertIn(self.syncer._CORE_DEV_MENTION, mock_channel.send.call_args[0][0]) - self.assertEqual(ret_val, mock_message) - - async def test_send_prompt_adds_reactions(self): - """The message should have reactions for confirmation added.""" - extant_message = helpers.MockMessage() - subtests = ( - (extant_message, lambda: (None, extant_message)), - (None, self.mock_get_channel), - (None, self.mock_fetch_channel), - ) - - for message_arg, mock_ in subtests: - subtest_msg = "Extant message" if mock_.__name__ == "" else mock_.__name__ - - with self.subTest(msg=subtest_msg): - _, mock_message = mock_() - await self.syncer._send_prompt(message_arg) - - calls = [mock.call(emoji) for emoji in self.syncer._REACTION_EMOJIS] - mock_message.add_reaction.assert_has_calls(calls) - - -class SyncerConfirmationTests(unittest.IsolatedAsyncioTestCase): - """Tests for waiting for a sync confirmation reaction on the prompt.""" - - def setUp(self): - self.bot = helpers.MockBot() - self.syncer = TestSyncer(self.bot) - self.core_dev_role = helpers.MockRole(id=constants.Roles.core_developers) - - @staticmethod - def get_message_reaction(emoji): - """Fixture to return a mock message an reaction from the given `emoji`.""" - message = helpers.MockMessage() - reaction = helpers.MockReaction(emoji=emoji, message=message) - - return message, reaction - - def test_reaction_check_for_valid_emoji_and_authors(self): - """Should return True if authors are identical or are a bot and a core dev, respectively.""" - user_subtests = ( - ( - helpers.MockMember(id=77), - helpers.MockMember(id=77), - "identical users", - ), - ( - helpers.MockMember(id=77, bot=True), - helpers.MockMember(id=43, roles=[self.core_dev_role]), - "bot author and core-dev reactor", - ), - ) - - for emoji in self.syncer._REACTION_EMOJIS: - for author, user, msg in user_subtests: - with self.subTest(author=author, user=user, emoji=emoji, msg=msg): - message, reaction = self.get_message_reaction(emoji) - ret_val = self.syncer._reaction_check(author, message, reaction, user) - - self.assertTrue(ret_val) - - def test_reaction_check_for_invalid_reactions(self): - """Should return False for invalid reaction events.""" - valid_emoji = self.syncer._REACTION_EMOJIS[0] - subtests = ( - ( - helpers.MockMember(id=77), - *self.get_message_reaction(valid_emoji), - helpers.MockMember(id=43, roles=[self.core_dev_role]), - "users are not identical", - ), - ( - helpers.MockMember(id=77, bot=True), - *self.get_message_reaction(valid_emoji), - helpers.MockMember(id=43), - "reactor lacks the core-dev role", - ), - ( - helpers.MockMember(id=77, bot=True, roles=[self.core_dev_role]), - *self.get_message_reaction(valid_emoji), - helpers.MockMember(id=77, bot=True, roles=[self.core_dev_role]), - "reactor is a bot", - ), - ( - helpers.MockMember(id=77), - helpers.MockMessage(id=95), - helpers.MockReaction(emoji=valid_emoji, message=helpers.MockMessage(id=26)), - helpers.MockMember(id=77), - "messages are not identical", - ), - ( - helpers.MockMember(id=77), - *self.get_message_reaction("InVaLiD"), - helpers.MockMember(id=77), - "emoji is invalid", - ), - ) - - for *args, msg in subtests: - kwargs = dict(zip(("author", "message", "reaction", "user"), args)) - with self.subTest(**kwargs, msg=msg): - ret_val = self.syncer._reaction_check(*args) - self.assertFalse(ret_val) - - async def test_wait_for_confirmation(self): - """The message should always be edited and only return True if the emoji is a check mark.""" - subtests = ( - (constants.Emojis.check_mark, True, None), - ("InVaLiD", False, None), - (None, False, asyncio.TimeoutError), - ) - - for emoji, ret_val, side_effect in subtests: - for bot in (True, False): - with self.subTest(emoji=emoji, ret_val=ret_val, side_effect=side_effect, bot=bot): - # Set up mocks - message = helpers.MockMessage() - member = helpers.MockMember(bot=bot) - - self.bot.wait_for.reset_mock() - self.bot.wait_for.return_value = (helpers.MockReaction(emoji=emoji), None) - self.bot.wait_for.side_effect = side_effect - - # Call the function - actual_return = await self.syncer._wait_for_confirmation(member, message) - - # Perform assertions - self.bot.wait_for.assert_called_once() - self.assertIn("reaction_add", self.bot.wait_for.call_args[0]) - - message.edit.assert_called_once() - kwargs = message.edit.call_args[1] - self.assertIn("content", kwargs) - - # Core devs should only be mentioned if the author is a bot. - if bot: - self.assertIn(self.syncer._CORE_DEV_MENTION, kwargs["content"]) - else: - self.assertNotIn(self.syncer._CORE_DEV_MENTION, kwargs["content"]) - - self.assertIs(actual_return, ret_val) - - -class SyncerSyncTests(unittest.IsolatedAsyncioTestCase): - """Tests for main function orchestrating the sync.""" - - def setUp(self): - self.bot = helpers.MockBot(user=helpers.MockMember(bot=True)) - self.syncer = TestSyncer(self.bot) - - async def test_sync_respects_confirmation_result(self): - """The sync should abort if confirmation fails and continue if confirmed.""" - mock_message = helpers.MockMessage() - subtests = ( - (True, mock_message), - (False, None), - ) - - for confirmed, message in subtests: - with self.subTest(confirmed=confirmed): - self.syncer._sync.reset_mock() - self.syncer._get_diff.reset_mock() - - diff = _Diff({1, 2, 3}, {4, 5}, None) - self.syncer._get_diff.return_value = diff - self.syncer._get_confirmation_result = mock.AsyncMock( - return_value=(confirmed, message) - ) - - guild = helpers.MockGuild() - await self.syncer.sync(guild) - - self.syncer._get_diff.assert_called_once_with(guild) - self.syncer._get_confirmation_result.assert_called_once() - - if confirmed: - self.syncer._sync.assert_called_once_with(diff) - else: - self.syncer._sync.assert_not_called() - - async def test_sync_diff_size(self): - """The diff size should be correctly calculated.""" - subtests = ( - (6, _Diff({1, 2}, {3, 4}, {5, 6})), - (5, _Diff({1, 2, 3}, None, {4, 5})), - (0, _Diff(None, None, None)), - (0, _Diff(set(), set(), set())), - ) - - for size, diff in subtests: - with self.subTest(size=size, diff=diff): - self.syncer._get_diff.reset_mock() - self.syncer._get_diff.return_value = diff - self.syncer._get_confirmation_result = mock.AsyncMock(return_value=(False, None)) - - guild = helpers.MockGuild() - await self.syncer.sync(guild) - - self.syncer._get_diff.assert_called_once_with(guild) - self.syncer._get_confirmation_result.assert_called_once() - self.assertEqual(self.syncer._get_confirmation_result.call_args[0][0], size) - - async def test_sync_message_edited(self): - """The message should be edited if one was sent, even if the sync has an API error.""" - subtests = ( - (None, None, False), - (helpers.MockMessage(), None, True), - (helpers.MockMessage(), ResponseCodeError(mock.MagicMock()), True), - ) - - for message, side_effect, should_edit in subtests: - with self.subTest(message=message, side_effect=side_effect, should_edit=should_edit): - self.syncer._sync.side_effect = side_effect - self.syncer._get_confirmation_result = mock.AsyncMock( - return_value=(True, message) - ) - - guild = helpers.MockGuild() - await self.syncer.sync(guild) - - if should_edit: - message.edit.assert_called_once() - self.assertIn("content", message.edit.call_args[1]) - - async def test_sync_confirmation_context_redirect(self): - """If ctx is given, a new message should be sent and author should be ctx's author.""" - mock_member = helpers.MockMember() - subtests = ( - (None, self.bot.user, None), - (helpers.MockContext(author=mock_member), mock_member, helpers.MockMessage()), - ) - - for ctx, author, message in subtests: - with self.subTest(ctx=ctx, author=author, message=message): - if ctx is not None: - ctx.send.return_value = message - - # Make sure `_get_diff` returns a MagicMock, not an AsyncMock - self.syncer._get_diff.return_value = mock.MagicMock() - - self.syncer._get_confirmation_result = mock.AsyncMock(return_value=(False, None)) - - guild = helpers.MockGuild() - await self.syncer.sync(guild, ctx) - - if ctx is not None: - ctx.send.assert_called_once() - - self.syncer._get_confirmation_result.assert_called_once() - self.assertEqual(self.syncer._get_confirmation_result.call_args[0][1], author) - self.assertEqual(self.syncer._get_confirmation_result.call_args[0][2], message) - - @mock.patch.object(constants.Sync, "max_diff", new=3) - async def test_confirmation_result_small_diff(self): - """Should always return True and the given message if the diff size is too small.""" - author = helpers.MockMember() - expected_message = helpers.MockMessage() - - for size in (3, 2): # pragma: no cover - with self.subTest(size=size): - self.syncer._send_prompt = mock.AsyncMock() - self.syncer._wait_for_confirmation = mock.AsyncMock() - - coro = self.syncer._get_confirmation_result(size, author, expected_message) - result, actual_message = await coro - - self.assertTrue(result) - self.assertEqual(actual_message, expected_message) - self.syncer._send_prompt.assert_not_called() - self.syncer._wait_for_confirmation.assert_not_called() - - @mock.patch.object(constants.Sync, "max_diff", new=3) - async def test_confirmation_result_large_diff(self): - """Should return True if confirmed and False if _send_prompt fails or aborted.""" - author = helpers.MockMember() - mock_message = helpers.MockMessage() - - subtests = ( - (True, mock_message, True, "confirmed"), - (False, None, False, "_send_prompt failed"), - (False, mock_message, False, "aborted"), - ) - - for expected_result, expected_message, confirmed, msg in subtests: # pragma: no cover - with self.subTest(msg=msg): - self.syncer._send_prompt = mock.AsyncMock(return_value=expected_message) - self.syncer._wait_for_confirmation = mock.AsyncMock(return_value=confirmed) - - coro = self.syncer._get_confirmation_result(4, author) - actual_result, actual_message = await coro - - self.syncer._send_prompt.assert_called_once_with(None) # message defaults to None - self.assertIs(actual_result, expected_result) - self.assertEqual(actual_message, expected_message) - - if expected_message: - self.syncer._wait_for_confirmation.assert_called_once_with( - author, expected_message - ) diff --git a/tests/bot/cogs/backend/sync/test_cog.py b/tests/bot/cogs/backend/sync/test_cog.py deleted file mode 100644 index e40552817..000000000 --- a/tests/bot/cogs/backend/sync/test_cog.py +++ /dev/null @@ -1,416 +0,0 @@ -import unittest -from unittest import mock - -import discord - -from bot import constants -from bot.api import ResponseCodeError -from bot.cogs.backend import sync -from bot.cogs.backend.sync._cog import Sync -from bot.cogs.backend.sync._syncers import Syncer -from tests import helpers -from tests.base import CommandTestCase - - -class SyncExtensionTests(unittest.IsolatedAsyncioTestCase): - """Tests for the sync extension.""" - - @staticmethod - def test_extension_setup(): - """The Sync cog should be added.""" - bot = helpers.MockBot() - sync.setup(bot) - bot.add_cog.assert_called_once() - - -class SyncCogTestCase(unittest.IsolatedAsyncioTestCase): - """Base class for Sync cog tests. Sets up patches for syncers.""" - - def setUp(self): - self.bot = helpers.MockBot() - - self.role_syncer_patcher = mock.patch( - "bot.cogs.backend.sync._syncers.RoleSyncer", - autospec=Syncer, - spec_set=True - ) - self.user_syncer_patcher = mock.patch( - "bot.cogs.backend.sync._syncers.UserSyncer", - autospec=Syncer, - spec_set=True - ) - self.RoleSyncer = self.role_syncer_patcher.start() - self.UserSyncer = self.user_syncer_patcher.start() - - self.cog = Sync(self.bot) - - def tearDown(self): - self.role_syncer_patcher.stop() - self.user_syncer_patcher.stop() - - @staticmethod - def response_error(status: int) -> ResponseCodeError: - """Fixture to return a ResponseCodeError with the given status code.""" - response = mock.MagicMock() - response.status = status - - return ResponseCodeError(response) - - -class SyncCogTests(SyncCogTestCase): - """Tests for the Sync cog.""" - - @mock.patch.object(Sync, "sync_guild", new_callable=mock.MagicMock) - def test_sync_cog_init(self, sync_guild): - """Should instantiate syncers and run a sync for the guild.""" - # Reset because a Sync cog was already instantiated in setUp. - self.RoleSyncer.reset_mock() - self.UserSyncer.reset_mock() - self.bot.loop.create_task = mock.MagicMock() - - mock_sync_guild_coro = mock.MagicMock() - sync_guild.return_value = mock_sync_guild_coro - - Sync(self.bot) - - self.RoleSyncer.assert_called_once_with(self.bot) - self.UserSyncer.assert_called_once_with(self.bot) - sync_guild.assert_called_once_with() - self.bot.loop.create_task.assert_called_once_with(mock_sync_guild_coro) - - async def test_sync_cog_sync_guild(self): - """Roles and users should be synced only if a guild is successfully retrieved.""" - for guild in (helpers.MockGuild(), None): - with self.subTest(guild=guild): - self.bot.reset_mock() - self.cog.role_syncer.reset_mock() - self.cog.user_syncer.reset_mock() - - self.bot.get_guild = mock.MagicMock(return_value=guild) - - await self.cog.sync_guild() - - self.bot.wait_until_guild_available.assert_called_once() - self.bot.get_guild.assert_called_once_with(constants.Guild.id) - - if guild is None: - self.cog.role_syncer.sync.assert_not_called() - self.cog.user_syncer.sync.assert_not_called() - else: - self.cog.role_syncer.sync.assert_called_once_with(guild) - self.cog.user_syncer.sync.assert_called_once_with(guild) - - async def patch_user_helper(self, side_effect: BaseException) -> None: - """Helper to set a side effect for bot.api_client.patch and then assert it is called.""" - self.bot.api_client.patch.reset_mock(side_effect=True) - self.bot.api_client.patch.side_effect = side_effect - - user_id, updated_information = 5, {"key": 123} - await self.cog.patch_user(user_id, updated_information) - - self.bot.api_client.patch.assert_called_once_with( - f"bot/users/{user_id}", - json=updated_information, - ) - - async def test_sync_cog_patch_user(self): - """A PATCH request should be sent and 404 errors ignored.""" - for side_effect in (None, self.response_error(404)): - with self.subTest(side_effect=side_effect): - await self.patch_user_helper(side_effect) - - async def test_sync_cog_patch_user_non_404(self): - """A PATCH request should be sent and the error raised if it's not a 404.""" - with self.assertRaises(ResponseCodeError): - await self.patch_user_helper(self.response_error(500)) - - -class SyncCogListenerTests(SyncCogTestCase): - """Tests for the listeners of the Sync cog.""" - - def setUp(self): - super().setUp() - self.cog.patch_user = mock.AsyncMock(spec_set=self.cog.patch_user) - - self.guild_id_patcher = mock.patch("bot.cogs.backend.sync._cog.constants.Guild.id", 5) - self.guild_id = self.guild_id_patcher.start() - - self.guild = helpers.MockGuild(id=self.guild_id) - self.other_guild = helpers.MockGuild(id=0) - - def tearDown(self): - self.guild_id_patcher.stop() - - async def test_sync_cog_on_guild_role_create(self): - """A POST request should be sent with the new role's data.""" - self.assertTrue(self.cog.on_guild_role_create.__cog_listener__) - - role_data = { - "colour": 49, - "id": 777, - "name": "rolename", - "permissions": 8, - "position": 23, - } - role = helpers.MockRole(**role_data, guild=self.guild) - await self.cog.on_guild_role_create(role) - - self.bot.api_client.post.assert_called_once_with("bot/roles", json=role_data) - - async def test_sync_cog_on_guild_role_create_ignores_guilds(self): - """Events from other guilds should be ignored.""" - role = helpers.MockRole(guild=self.other_guild) - await self.cog.on_guild_role_create(role) - self.bot.api_client.post.assert_not_awaited() - - async def test_sync_cog_on_guild_role_delete(self): - """A DELETE request should be sent.""" - self.assertTrue(self.cog.on_guild_role_delete.__cog_listener__) - - role = helpers.MockRole(id=99, guild=self.guild) - await self.cog.on_guild_role_delete(role) - - self.bot.api_client.delete.assert_called_once_with("bot/roles/99") - - async def test_sync_cog_on_guild_role_delete_ignores_guilds(self): - """Events from other guilds should be ignored.""" - role = helpers.MockRole(guild=self.other_guild) - await self.cog.on_guild_role_delete(role) - self.bot.api_client.delete.assert_not_awaited() - - async def test_sync_cog_on_guild_role_update(self): - """A PUT request should be sent if the colour, name, permissions, or position changes.""" - self.assertTrue(self.cog.on_guild_role_update.__cog_listener__) - - role_data = { - "colour": 49, - "id": 777, - "name": "rolename", - "permissions": 8, - "position": 23, - } - subtests = ( - (True, ("colour", "name", "permissions", "position")), - (False, ("hoist", "mentionable")), - ) - - for should_put, attributes in subtests: - for attribute in attributes: - with self.subTest(should_put=should_put, changed_attribute=attribute): - self.bot.api_client.put.reset_mock() - - after_role_data = role_data.copy() - after_role_data[attribute] = 876 - - before_role = helpers.MockRole(**role_data, guild=self.guild) - after_role = helpers.MockRole(**after_role_data, guild=self.guild) - - await self.cog.on_guild_role_update(before_role, after_role) - - if should_put: - self.bot.api_client.put.assert_called_once_with( - f"bot/roles/{after_role.id}", - json=after_role_data - ) - else: - self.bot.api_client.put.assert_not_called() - - async def test_sync_cog_on_guild_role_update_ignores_guilds(self): - """Events from other guilds should be ignored.""" - role = helpers.MockRole(guild=self.other_guild) - await self.cog.on_guild_role_update(role, role) - self.bot.api_client.put.assert_not_awaited() - - async def test_sync_cog_on_member_remove(self): - """Member should be patched to set in_guild as False.""" - self.assertTrue(self.cog.on_member_remove.__cog_listener__) - - member = helpers.MockMember(guild=self.guild) - await self.cog.on_member_remove(member) - - self.cog.patch_user.assert_called_once_with( - member.id, - json={"in_guild": False} - ) - - async def test_sync_cog_on_member_remove_ignores_guilds(self): - """Events from other guilds should be ignored.""" - member = helpers.MockMember(guild=self.other_guild) - await self.cog.on_member_remove(member) - self.cog.patch_user.assert_not_awaited() - - async def test_sync_cog_on_member_update_roles(self): - """Members should be patched if their roles have changed.""" - self.assertTrue(self.cog.on_member_update.__cog_listener__) - - # Roles are intentionally unsorted. - before_roles = [helpers.MockRole(id=12), helpers.MockRole(id=30), helpers.MockRole(id=20)] - before_member = helpers.MockMember(roles=before_roles, guild=self.guild) - after_member = helpers.MockMember(roles=before_roles[1:], guild=self.guild) - - await self.cog.on_member_update(before_member, after_member) - - data = {"roles": sorted(role.id for role in after_member.roles)} - self.cog.patch_user.assert_called_once_with(after_member.id, json=data) - - async def test_sync_cog_on_member_update_other(self): - """Members should not be patched if other attributes have changed.""" - self.assertTrue(self.cog.on_member_update.__cog_listener__) - - subtests = ( - ("activities", discord.Game("Pong"), discord.Game("Frogger")), - ("nick", "old nick", "new nick"), - ("status", discord.Status.online, discord.Status.offline), - ) - - for attribute, old_value, new_value in subtests: - with self.subTest(attribute=attribute): - self.cog.patch_user.reset_mock() - - before_member = helpers.MockMember(**{attribute: old_value}, guild=self.guild) - after_member = helpers.MockMember(**{attribute: new_value}, guild=self.guild) - - await self.cog.on_member_update(before_member, after_member) - - self.cog.patch_user.assert_not_called() - - async def test_sync_cog_on_member_update_ignores_guilds(self): - """Events from other guilds should be ignored.""" - member = helpers.MockMember(guild=self.other_guild) - await self.cog.on_member_update(member, member) - self.cog.patch_user.assert_not_awaited() - - async def test_sync_cog_on_user_update(self): - """A user should be patched only if the name, discriminator, or avatar changes.""" - self.assertTrue(self.cog.on_user_update.__cog_listener__) - - before_data = { - "name": "old name", - "discriminator": "1234", - "bot": False, - } - - subtests = ( - (True, "name", "name", "new name", "new name"), - (True, "discriminator", "discriminator", "8765", 8765), - (False, "bot", "bot", True, True), - ) - - for should_patch, attribute, api_field, value, api_value in subtests: - with self.subTest(attribute=attribute): - self.cog.patch_user.reset_mock() - - after_data = before_data.copy() - after_data[attribute] = value - before_user = helpers.MockUser(**before_data) - after_user = helpers.MockUser(**after_data) - - await self.cog.on_user_update(before_user, after_user) - - if should_patch: - self.cog.patch_user.assert_called_once() - - # Don't care if *all* keys are present; only the changed one is required - call_args = self.cog.patch_user.call_args - self.assertEqual(call_args.args[0], after_user.id) - self.assertIn("json", call_args.kwargs) - - self.assertIn("ignore_404", call_args.kwargs) - self.assertTrue(call_args.kwargs["ignore_404"]) - - json = call_args.kwargs["json"] - self.assertIn(api_field, json) - self.assertEqual(json[api_field], api_value) - else: - self.cog.patch_user.assert_not_called() - - async def on_member_join_helper(self, side_effect: Exception) -> dict: - """ - Helper to set `side_effect` for on_member_join and assert a PUT request was sent. - - The request data for the mock member is returned. All exceptions will be re-raised. - """ - member = helpers.MockMember( - discriminator="1234", - roles=[helpers.MockRole(id=22), helpers.MockRole(id=12)], - guild=self.guild, - ) - - data = { - "discriminator": int(member.discriminator), - "id": member.id, - "in_guild": True, - "name": member.name, - "roles": sorted(role.id for role in member.roles) - } - - self.bot.api_client.put.reset_mock(side_effect=True) - self.bot.api_client.put.side_effect = side_effect - - try: - await self.cog.on_member_join(member) - except Exception: - raise - finally: - self.bot.api_client.put.assert_called_once_with( - f"bot/users/{member.id}", - json=data - ) - - return data - - async def test_sync_cog_on_member_join(self): - """Should PUT user's data or POST it if the user doesn't exist.""" - for side_effect in (None, self.response_error(404)): - with self.subTest(side_effect=side_effect): - self.bot.api_client.post.reset_mock() - data = await self.on_member_join_helper(side_effect) - - if side_effect: - self.bot.api_client.post.assert_called_once_with("bot/users", json=data) - else: - self.bot.api_client.post.assert_not_called() - - async def test_sync_cog_on_member_join_non_404(self): - """ResponseCodeError should be re-raised if status code isn't a 404.""" - with self.assertRaises(ResponseCodeError): - await self.on_member_join_helper(self.response_error(500)) - - self.bot.api_client.post.assert_not_called() - - async def test_sync_cog_on_member_join_ignores_guilds(self): - """Events from other guilds should be ignored.""" - member = helpers.MockMember(guild=self.other_guild) - await self.cog.on_member_join(member) - self.bot.api_client.post.assert_not_awaited() - self.bot.api_client.put.assert_not_awaited() - - -class SyncCogCommandTests(SyncCogTestCase, CommandTestCase): - """Tests for the commands in the Sync cog.""" - - async def test_sync_roles_command(self): - """sync() should be called on the RoleSyncer.""" - ctx = helpers.MockContext() - await self.cog.sync_roles_command.callback(self.cog, ctx) - - self.cog.role_syncer.sync.assert_called_once_with(ctx.guild, ctx) - - async def test_sync_users_command(self): - """sync() should be called on the UserSyncer.""" - ctx = helpers.MockContext() - await self.cog.sync_users_command.callback(self.cog, ctx) - - self.cog.user_syncer.sync.assert_called_once_with(ctx.guild, ctx) - - async def test_commands_require_admin(self): - """The sync commands should only run if the author has the administrator permission.""" - cmds = ( - self.cog.sync_group, - self.cog.sync_roles_command, - self.cog.sync_users_command, - ) - - for cmd in cmds: - with self.subTest(cmd=cmd): - await self.assertHasPermissionsCheck(cmd, {"administrator": True}) diff --git a/tests/bot/cogs/backend/sync/test_roles.py b/tests/bot/cogs/backend/sync/test_roles.py deleted file mode 100644 index 99d682ede..000000000 --- a/tests/bot/cogs/backend/sync/test_roles.py +++ /dev/null @@ -1,157 +0,0 @@ -import unittest -from unittest import mock - -import discord - -from bot.cogs.backend.sync._syncers import RoleSyncer, _Diff, _Role -from tests import helpers - - -def fake_role(**kwargs): - """Fixture to return a dictionary representing a role with default values set.""" - kwargs.setdefault("id", 9) - kwargs.setdefault("name", "fake role") - kwargs.setdefault("colour", 7) - kwargs.setdefault("permissions", 0) - kwargs.setdefault("position", 55) - - return kwargs - - -class RoleSyncerDiffTests(unittest.IsolatedAsyncioTestCase): - """Tests for determining differences between roles in the DB and roles in the Guild cache.""" - - def setUp(self): - self.bot = helpers.MockBot() - self.syncer = RoleSyncer(self.bot) - - @staticmethod - def get_guild(*roles): - """Fixture to return a guild object with the given roles.""" - guild = helpers.MockGuild() - guild.roles = [] - - for role in roles: - mock_role = helpers.MockRole(**role) - mock_role.colour = discord.Colour(role["colour"]) - mock_role.permissions = discord.Permissions(role["permissions"]) - guild.roles.append(mock_role) - - return guild - - async def test_empty_diff_for_identical_roles(self): - """No differences should be found if the roles in the guild and DB are identical.""" - self.bot.api_client.get.return_value = [fake_role()] - guild = self.get_guild(fake_role()) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), set(), set()) - - self.assertEqual(actual_diff, expected_diff) - - async def test_diff_for_updated_roles(self): - """Only updated roles should be added to the 'updated' set of the diff.""" - updated_role = fake_role(id=41, name="new") - - self.bot.api_client.get.return_value = [fake_role(id=41, name="old"), fake_role()] - guild = self.get_guild(updated_role, fake_role()) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), {_Role(**updated_role)}, set()) - - self.assertEqual(actual_diff, expected_diff) - - async def test_diff_for_new_roles(self): - """Only new roles should be added to the 'created' set of the diff.""" - new_role = fake_role(id=41, name="new") - - self.bot.api_client.get.return_value = [fake_role()] - guild = self.get_guild(fake_role(), new_role) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = ({_Role(**new_role)}, set(), set()) - - self.assertEqual(actual_diff, expected_diff) - - async def test_diff_for_deleted_roles(self): - """Only deleted roles should be added to the 'deleted' set of the diff.""" - deleted_role = fake_role(id=61, name="deleted") - - self.bot.api_client.get.return_value = [fake_role(), deleted_role] - guild = self.get_guild(fake_role()) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), set(), {_Role(**deleted_role)}) - - self.assertEqual(actual_diff, expected_diff) - - async def test_diff_for_new_updated_and_deleted_roles(self): - """When roles are added, updated, and removed, all of them are returned properly.""" - new = fake_role(id=41, name="new") - updated = fake_role(id=71, name="updated") - deleted = fake_role(id=61, name="deleted") - - self.bot.api_client.get.return_value = [ - fake_role(), - fake_role(id=71, name="updated name"), - deleted, - ] - guild = self.get_guild(fake_role(), new, updated) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = ({_Role(**new)}, {_Role(**updated)}, {_Role(**deleted)}) - - self.assertEqual(actual_diff, expected_diff) - - -class RoleSyncerSyncTests(unittest.IsolatedAsyncioTestCase): - """Tests for the API requests that sync roles.""" - - def setUp(self): - self.bot = helpers.MockBot() - self.syncer = RoleSyncer(self.bot) - - async def test_sync_created_roles(self): - """Only POST requests should be made with the correct payload.""" - roles = [fake_role(id=111), fake_role(id=222)] - - role_tuples = {_Role(**role) for role in roles} - diff = _Diff(role_tuples, set(), set()) - await self.syncer._sync(diff) - - calls = [mock.call("bot/roles", json=role) for role in roles] - self.bot.api_client.post.assert_has_calls(calls, any_order=True) - self.assertEqual(self.bot.api_client.post.call_count, len(roles)) - - self.bot.api_client.put.assert_not_called() - self.bot.api_client.delete.assert_not_called() - - async def test_sync_updated_roles(self): - """Only PUT requests should be made with the correct payload.""" - roles = [fake_role(id=111), fake_role(id=222)] - - role_tuples = {_Role(**role) for role in roles} - diff = _Diff(set(), role_tuples, set()) - await self.syncer._sync(diff) - - calls = [mock.call(f"bot/roles/{role['id']}", json=role) for role in roles] - self.bot.api_client.put.assert_has_calls(calls, any_order=True) - self.assertEqual(self.bot.api_client.put.call_count, len(roles)) - - self.bot.api_client.post.assert_not_called() - self.bot.api_client.delete.assert_not_called() - - async def test_sync_deleted_roles(self): - """Only DELETE requests should be made with the correct payload.""" - roles = [fake_role(id=111), fake_role(id=222)] - - role_tuples = {_Role(**role) for role in roles} - diff = _Diff(set(), set(), role_tuples) - await self.syncer._sync(diff) - - calls = [mock.call(f"bot/roles/{role['id']}") for role in roles] - self.bot.api_client.delete.assert_has_calls(calls, any_order=True) - self.assertEqual(self.bot.api_client.delete.call_count, len(roles)) - - self.bot.api_client.post.assert_not_called() - self.bot.api_client.put.assert_not_called() diff --git a/tests/bot/cogs/backend/sync/test_users.py b/tests/bot/cogs/backend/sync/test_users.py deleted file mode 100644 index 51dcbe48a..000000000 --- a/tests/bot/cogs/backend/sync/test_users.py +++ /dev/null @@ -1,158 +0,0 @@ -import unittest -from unittest import mock - -from bot.cogs.backend.sync._syncers import UserSyncer, _Diff, _User -from tests import helpers - - -def fake_user(**kwargs): - """Fixture to return a dictionary representing a user with default values set.""" - kwargs.setdefault("id", 43) - kwargs.setdefault("name", "bob the test man") - kwargs.setdefault("discriminator", 1337) - kwargs.setdefault("roles", (666,)) - kwargs.setdefault("in_guild", True) - - return kwargs - - -class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): - """Tests for determining differences between users in the DB and users in the Guild cache.""" - - def setUp(self): - self.bot = helpers.MockBot() - self.syncer = UserSyncer(self.bot) - - @staticmethod - def get_guild(*members): - """Fixture to return a guild object with the given members.""" - guild = helpers.MockGuild() - guild.members = [] - - for member in members: - member = member.copy() - del member["in_guild"] - - mock_member = helpers.MockMember(**member) - mock_member.roles = [helpers.MockRole(id=role_id) for role_id in member["roles"]] - - guild.members.append(mock_member) - - return guild - - async def test_empty_diff_for_no_users(self): - """When no users are given, an empty diff should be returned.""" - guild = self.get_guild() - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), set(), None) - - self.assertEqual(actual_diff, expected_diff) - - async def test_empty_diff_for_identical_users(self): - """No differences should be found if the users in the guild and DB are identical.""" - self.bot.api_client.get.return_value = [fake_user()] - guild = self.get_guild(fake_user()) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), set(), None) - - self.assertEqual(actual_diff, expected_diff) - - async def test_diff_for_updated_users(self): - """Only updated users should be added to the 'updated' set of the diff.""" - updated_user = fake_user(id=99, name="new") - - self.bot.api_client.get.return_value = [fake_user(id=99, name="old"), fake_user()] - guild = self.get_guild(updated_user, fake_user()) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), {_User(**updated_user)}, None) - - self.assertEqual(actual_diff, expected_diff) - - async def test_diff_for_new_users(self): - """Only new users should be added to the 'created' set of the diff.""" - new_user = fake_user(id=99, name="new") - - self.bot.api_client.get.return_value = [fake_user()] - guild = self.get_guild(fake_user(), new_user) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = ({_User(**new_user)}, set(), None) - - self.assertEqual(actual_diff, expected_diff) - - async def test_diff_sets_in_guild_false_for_leaving_users(self): - """When a user leaves the guild, the `in_guild` flag is updated to `False`.""" - leaving_user = fake_user(id=63, in_guild=False) - - self.bot.api_client.get.return_value = [fake_user(), fake_user(id=63)] - guild = self.get_guild(fake_user()) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), {_User(**leaving_user)}, None) - - self.assertEqual(actual_diff, expected_diff) - - async def test_diff_for_new_updated_and_leaving_users(self): - """When users are added, updated, and removed, all of them are returned properly.""" - new_user = fake_user(id=99, name="new") - updated_user = fake_user(id=55, name="updated") - leaving_user = fake_user(id=63, in_guild=False) - - self.bot.api_client.get.return_value = [fake_user(), fake_user(id=55), fake_user(id=63)] - guild = self.get_guild(fake_user(), new_user, updated_user) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = ({_User(**new_user)}, {_User(**updated_user), _User(**leaving_user)}, None) - - self.assertEqual(actual_diff, expected_diff) - - async def test_empty_diff_for_db_users_not_in_guild(self): - """When the DB knows a user the guild doesn't, no difference is found.""" - self.bot.api_client.get.return_value = [fake_user(), fake_user(id=63, in_guild=False)] - guild = self.get_guild(fake_user()) - - actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), set(), None) - - self.assertEqual(actual_diff, expected_diff) - - -class UserSyncerSyncTests(unittest.IsolatedAsyncioTestCase): - """Tests for the API requests that sync users.""" - - def setUp(self): - self.bot = helpers.MockBot() - self.syncer = UserSyncer(self.bot) - - async def test_sync_created_users(self): - """Only POST requests should be made with the correct payload.""" - users = [fake_user(id=111), fake_user(id=222)] - - user_tuples = {_User(**user) for user in users} - diff = _Diff(user_tuples, set(), None) - await self.syncer._sync(diff) - - calls = [mock.call("bot/users", json=user) for user in users] - self.bot.api_client.post.assert_has_calls(calls, any_order=True) - self.assertEqual(self.bot.api_client.post.call_count, len(users)) - - self.bot.api_client.put.assert_not_called() - self.bot.api_client.delete.assert_not_called() - - async def test_sync_updated_users(self): - """Only PUT requests should be made with the correct payload.""" - users = [fake_user(id=111), fake_user(id=222)] - - user_tuples = {_User(**user) for user in users} - diff = _Diff(set(), user_tuples, None) - await self.syncer._sync(diff) - - calls = [mock.call(f"bot/users/{user['id']}", json=user) for user in users] - self.bot.api_client.put.assert_has_calls(calls, any_order=True) - self.assertEqual(self.bot.api_client.put.call_count, len(users)) - - self.bot.api_client.post.assert_not_called() - self.bot.api_client.delete.assert_not_called() diff --git a/tests/bot/cogs/backend/test_logging.py b/tests/bot/cogs/backend/test_logging.py deleted file mode 100644 index c867773e2..000000000 --- a/tests/bot/cogs/backend/test_logging.py +++ /dev/null @@ -1,32 +0,0 @@ -import unittest -from unittest.mock import patch - -from bot import constants -from bot.cogs.backend.logging import Logging -from tests.helpers import MockBot, MockTextChannel - - -class LoggingTests(unittest.IsolatedAsyncioTestCase): - """Test cases for connected login.""" - - def setUp(self): - self.bot = MockBot() - self.cog = Logging(self.bot) - self.dev_log = MockTextChannel(id=1234, name="dev-log") - - @patch("bot.cogs.backend.logging.DEBUG_MODE", False) - async def test_debug_mode_false(self): - """Should send connected message to dev-log.""" - self.bot.get_channel.return_value = self.dev_log - - await self.cog.startup_greeting() - self.bot.wait_until_guild_available.assert_awaited_once_with() - self.bot.get_channel.assert_called_once_with(constants.Channels.dev_log) - self.dev_log.send.assert_awaited_once() - - @patch("bot.cogs.backend.logging.DEBUG_MODE", True) - async def test_debug_mode_true(self): - """Should not send anything to dev-log.""" - await self.cog.startup_greeting() - self.bot.wait_until_guild_available.assert_awaited_once_with() - self.bot.get_channel.assert_not_called() diff --git a/tests/bot/cogs/filters/__init__.py b/tests/bot/cogs/filters/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/bot/cogs/filters/test_antimalware.py b/tests/bot/cogs/filters/test_antimalware.py deleted file mode 100644 index b00211f47..000000000 --- a/tests/bot/cogs/filters/test_antimalware.py +++ /dev/null @@ -1,165 +0,0 @@ -import unittest -from unittest.mock import AsyncMock, Mock - -from discord import NotFound - -from bot.cogs.filters import antimalware -from bot.constants import Channels, STAFF_ROLES -from tests.helpers import MockAttachment, MockBot, MockMessage, MockRole - - -class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): - """Test the AntiMalware cog.""" - - def setUp(self): - """Sets up fresh objects for each test.""" - self.bot = MockBot() - self.bot.filter_list_cache = { - "FILE_FORMAT.True": { - ".first": {}, - ".second": {}, - ".third": {}, - } - } - self.cog = antimalware.AntiMalware(self.bot) - self.message = MockMessage() - self.whitelist = [".first", ".second", ".third"] - - async def test_message_with_allowed_attachment(self): - """Messages with allowed extensions should not be deleted""" - attachment = MockAttachment(filename="python.first") - self.message.attachments = [attachment] - - await self.cog.on_message(self.message) - self.message.delete.assert_not_called() - - async def test_message_without_attachment(self): - """Messages without attachments should result in no action.""" - await self.cog.on_message(self.message) - self.message.delete.assert_not_called() - - async def test_direct_message_with_attachment(self): - """Direct messages should have no action taken.""" - attachment = MockAttachment(filename="python.disallowed") - self.message.attachments = [attachment] - self.message.guild = None - - await self.cog.on_message(self.message) - - self.message.delete.assert_not_called() - - async def test_message_with_illegal_extension_gets_deleted(self): - """A message containing an illegal extension should send an embed.""" - attachment = MockAttachment(filename="python.disallowed") - self.message.attachments = [attachment] - - await self.cog.on_message(self.message) - - self.message.delete.assert_called_once() - - async def test_message_send_by_staff(self): - """A message send by a member of staff should be ignored.""" - staff_role = MockRole(id=STAFF_ROLES[0]) - self.message.author.roles.append(staff_role) - attachment = MockAttachment(filename="python.disallowed") - self.message.attachments = [attachment] - - await self.cog.on_message(self.message) - - self.message.delete.assert_not_called() - - async def test_python_file_redirect_embed_description(self): - """A message containing a .py file should result in an embed redirecting the user to our paste site""" - attachment = MockAttachment(filename="python.py") - self.message.attachments = [attachment] - self.message.channel.send = AsyncMock() - - await self.cog.on_message(self.message) - self.message.channel.send.assert_called_once() - args, kwargs = self.message.channel.send.call_args - embed = kwargs.pop("embed") - - self.assertEqual(embed.description, antimalware.PY_EMBED_DESCRIPTION) - - async def test_txt_file_redirect_embed_description(self): - """A message containing a .txt file should result in the correct embed.""" - attachment = MockAttachment(filename="python.txt") - self.message.attachments = [attachment] - self.message.channel.send = AsyncMock() - antimalware.TXT_EMBED_DESCRIPTION = Mock() - antimalware.TXT_EMBED_DESCRIPTION.format.return_value = "test" - - await self.cog.on_message(self.message) - self.message.channel.send.assert_called_once() - args, kwargs = self.message.channel.send.call_args - embed = kwargs.pop("embed") - cmd_channel = self.bot.get_channel(Channels.bot_commands) - - self.assertEqual(embed.description, antimalware.TXT_EMBED_DESCRIPTION.format.return_value) - antimalware.TXT_EMBED_DESCRIPTION.format.assert_called_with(cmd_channel_mention=cmd_channel.mention) - - async def test_other_disallowed_extension_embed_description(self): - """Test the description for a non .py/.txt disallowed extension.""" - attachment = MockAttachment(filename="python.disallowed") - self.message.attachments = [attachment] - self.message.channel.send = AsyncMock() - antimalware.DISALLOWED_EMBED_DESCRIPTION = Mock() - antimalware.DISALLOWED_EMBED_DESCRIPTION.format.return_value = "test" - - await self.cog.on_message(self.message) - self.message.channel.send.assert_called_once() - args, kwargs = self.message.channel.send.call_args - embed = kwargs.pop("embed") - meta_channel = self.bot.get_channel(Channels.meta) - - self.assertEqual(embed.description, antimalware.DISALLOWED_EMBED_DESCRIPTION.format.return_value) - antimalware.DISALLOWED_EMBED_DESCRIPTION.format.assert_called_with( - joined_whitelist=", ".join(self.whitelist), - blocked_extensions_str=".disallowed", - meta_channel_mention=meta_channel.mention - ) - - async def test_removing_deleted_message_logs(self): - """Removing an already deleted message logs the correct message""" - attachment = MockAttachment(filename="python.disallowed") - self.message.attachments = [attachment] - self.message.delete = AsyncMock(side_effect=NotFound(response=Mock(status=""), message="")) - - with self.assertLogs(logger=antimalware.log, level="INFO"): - await self.cog.on_message(self.message) - self.message.delete.assert_called_once() - - async def test_message_with_illegal_attachment_logs(self): - """Deleting a message with an illegal attachment should result in a log.""" - attachment = MockAttachment(filename="python.disallowed") - self.message.attachments = [attachment] - - with self.assertLogs(logger=antimalware.log, level="INFO"): - await self.cog.on_message(self.message) - - async def test_get_disallowed_extensions(self): - """The return value should include all non-whitelisted extensions.""" - test_values = ( - ([], []), - (self.whitelist, []), - ([".first"], []), - ([".first", ".disallowed"], [".disallowed"]), - ([".disallowed"], [".disallowed"]), - ([".disallowed", ".illegal"], [".disallowed", ".illegal"]), - ) - - for extensions, expected_disallowed_extensions in test_values: - with self.subTest(extensions=extensions, expected_disallowed_extensions=expected_disallowed_extensions): - self.message.attachments = [MockAttachment(filename=f"filename{extension}") for extension in extensions] - disallowed_extensions = self.cog._get_disallowed_extensions(self.message) - self.assertCountEqual(disallowed_extensions, expected_disallowed_extensions) - - -class AntiMalwareSetupTests(unittest.TestCase): - """Tests setup of the `AntiMalware` cog.""" - - def test_setup(self): - """Setup of the extension should call add_cog.""" - bot = MockBot() - antimalware.setup(bot) - bot.add_cog.assert_called_once() diff --git a/tests/bot/cogs/filters/test_antispam.py b/tests/bot/cogs/filters/test_antispam.py deleted file mode 100644 index 8a3d8d02e..000000000 --- a/tests/bot/cogs/filters/test_antispam.py +++ /dev/null @@ -1,35 +0,0 @@ -import unittest - -from bot.cogs.filters import antispam - - -class AntispamConfigurationValidationTests(unittest.TestCase): - """Tests validation of the antispam cog configuration.""" - - def test_default_antispam_config_is_valid(self): - """The default antispam configuration is valid.""" - validation_errors = antispam.validate_config() - self.assertEqual(validation_errors, {}) - - def test_unknown_rule_returns_error(self): - """Configuring an unknown rule returns an error.""" - self.assertEqual( - antispam.validate_config({'invalid-rule': {}}), - {'invalid-rule': "`invalid-rule` is not recognized as an antispam rule."} - ) - - def test_missing_keys_returns_error(self): - """Not configuring required keys returns an error.""" - keys = (('interval', 'max'), ('max', 'interval')) - for configured_key, unconfigured_key in keys: - with self.subTest( - configured_key=configured_key, - unconfigured_key=unconfigured_key - ): - config = {'burst': {configured_key: 10}} - error = f"Key `{unconfigured_key}` is required but not set for rule `burst`" - - self.assertEqual( - antispam.validate_config(config), - {'burst': error} - ) diff --git a/tests/bot/cogs/filters/test_security.py b/tests/bot/cogs/filters/test_security.py deleted file mode 100644 index 82679f69c..000000000 --- a/tests/bot/cogs/filters/test_security.py +++ /dev/null @@ -1,54 +0,0 @@ -import unittest -from unittest.mock import MagicMock - -from discord.ext.commands import NoPrivateMessage - -from bot.cogs.filters import security -from tests.helpers import MockBot, MockContext - - -class SecurityCogTests(unittest.TestCase): - """Tests the `Security` cog.""" - - def setUp(self): - """Attach an instance of the cog to the class for tests.""" - self.bot = MockBot() - self.cog = security.Security(self.bot) - self.ctx = MockContext() - - def test_check_additions(self): - """The cog should add its checks after initialization.""" - self.bot.check.assert_any_call(self.cog.check_on_guild) - self.bot.check.assert_any_call(self.cog.check_not_bot) - - def test_check_not_bot_returns_false_for_humans(self): - """The bot check should return `True` when invoked with human authors.""" - self.ctx.author.bot = False - self.assertTrue(self.cog.check_not_bot(self.ctx)) - - def test_check_not_bot_returns_true_for_robots(self): - """The bot check should return `False` when invoked with robotic authors.""" - self.ctx.author.bot = True - self.assertFalse(self.cog.check_not_bot(self.ctx)) - - def test_check_on_guild_raises_when_outside_of_guild(self): - """When invoked outside of a guild, `check_on_guild` should cause an error.""" - self.ctx.guild = None - - with self.assertRaises(NoPrivateMessage, msg="This command cannot be used in private messages."): - self.cog.check_on_guild(self.ctx) - - def test_check_on_guild_returns_true_inside_of_guild(self): - """When invoked inside of a guild, `check_on_guild` should return `True`.""" - self.ctx.guild = "lemon's lemonade stand" - self.assertTrue(self.cog.check_on_guild(self.ctx)) - - -class SecurityCogLoadTests(unittest.TestCase): - """Tests loading the `Security` cog.""" - - def test_security_cog_load(self): - """Setup of the extension should call add_cog.""" - bot = MagicMock() - security.setup(bot) - bot.add_cog.assert_called_once() diff --git a/tests/bot/cogs/filters/test_token_remover.py b/tests/bot/cogs/filters/test_token_remover.py deleted file mode 100644 index 55b284ef9..000000000 --- a/tests/bot/cogs/filters/test_token_remover.py +++ /dev/null @@ -1,310 +0,0 @@ -import unittest -from re import Match -from unittest import mock -from unittest.mock import MagicMock - -from discord import Colour, NotFound - -from bot import constants -from bot.cogs.filters import token_remover -from bot.cogs.filters.token_remover import Token, TokenRemover -from bot.cogs.moderation.modlog import ModLog -from tests.helpers import MockBot, MockMessage, autospec - - -class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): - """Tests the `TokenRemover` cog.""" - - def setUp(self): - """Adds the cog, a bot, and a message to the instance for usage in tests.""" - self.bot = MockBot() - self.cog = TokenRemover(bot=self.bot) - - self.msg = MockMessage(id=555, content="hello world") - self.msg.channel.mention = "#lemonade-stand" - self.msg.author.__str__ = MagicMock(return_value=self.msg.author.name) - self.msg.author.avatar_url_as.return_value = "picture-lemon.png" - - def test_is_valid_user_id_valid(self): - """Should consider user IDs valid if they decode entirely to ASCII digits.""" - ids = ( - "NDcyMjY1OTQzMDYyNDEzMzMy", - "NDc1MDczNjI5Mzk5NTQ3OTA0", - "NDY3MjIzMjMwNjUwNzc3NjQx", - ) - - for user_id in ids: - with self.subTest(user_id=user_id): - result = TokenRemover.is_valid_user_id(user_id) - self.assertTrue(result) - - def test_is_valid_user_id_invalid(self): - """Should consider non-digit and non-ASCII IDs invalid.""" - ids = ( - ("SGVsbG8gd29ybGQ", "non-digit ASCII"), - ("0J_RgNC40LLQtdGCINC80LjRgA", "cyrillic text"), - ("4pO14p6L4p6C4pG34p264pGl8J-EiOKSj-KCieKBsA", "Unicode digits"), - ("4oaA4oaB4oWh4oWi4Lyz4Lyq4Lyr4LG9", "Unicode numerals"), - ("8J2fjvCdn5nwnZ-k8J2fr_Cdn7rgravvvJngr6c", "Unicode decimals"), - ("{hello}[world]&(bye!)", "ASCII invalid Base64"), - ("Þíß-ï§-ňøẗ-våłìÐ", "Unicode invalid Base64"), - ) - - for user_id, msg in ids: - with self.subTest(msg=msg): - result = TokenRemover.is_valid_user_id(user_id) - self.assertFalse(result) - - def test_is_valid_timestamp_valid(self): - """Should consider timestamps valid if they're greater than the Discord epoch.""" - timestamps = ( - "XsyRkw", - "Xrim9Q", - "XsyR-w", - "XsySD_", - "Dn9r_A", - ) - - for timestamp in timestamps: - with self.subTest(timestamp=timestamp): - result = TokenRemover.is_valid_timestamp(timestamp) - self.assertTrue(result) - - def test_is_valid_timestamp_invalid(self): - """Should consider timestamps invalid if they're before Discord epoch or can't be parsed.""" - timestamps = ( - ("B4Yffw", "DISCORD_EPOCH - TOKEN_EPOCH - 1"), - ("ew", "123"), - ("AoIKgA", "42076800"), - ("{hello}[world]&(bye!)", "ASCII invalid Base64"), - ("Þíß-ï§-ňøẗ-våłìÐ", "Unicode invalid Base64"), - ) - - for timestamp, msg in timestamps: - with self.subTest(msg=msg): - result = TokenRemover.is_valid_timestamp(timestamp) - self.assertFalse(result) - - def test_mod_log_property(self): - """The `mod_log` property should ask the bot to return the `ModLog` cog.""" - self.bot.get_cog.return_value = 'lemon' - self.assertEqual(self.cog.mod_log, self.bot.get_cog.return_value) - self.bot.get_cog.assert_called_once_with('ModLog') - - async def test_on_message_edit_uses_on_message(self): - """The edit listener should delegate handling of the message to the normal listener.""" - self.cog.on_message = mock.create_autospec(self.cog.on_message, spec_set=True) - - await self.cog.on_message_edit(MockMessage(), self.msg) - self.cog.on_message.assert_awaited_once_with(self.msg) - - @autospec(TokenRemover, "find_token_in_message", "take_action") - async def test_on_message_takes_action(self, find_token_in_message, take_action): - """Should take action if a valid token is found when a message is sent.""" - cog = TokenRemover(self.bot) - found_token = "foobar" - find_token_in_message.return_value = found_token - - await cog.on_message(self.msg) - - find_token_in_message.assert_called_once_with(self.msg) - take_action.assert_awaited_once_with(cog, self.msg, found_token) - - @autospec(TokenRemover, "find_token_in_message", "take_action") - async def test_on_message_skips_missing_token(self, find_token_in_message, take_action): - """Shouldn't take action if a valid token isn't found when a message is sent.""" - cog = TokenRemover(self.bot) - find_token_in_message.return_value = False - - await cog.on_message(self.msg) - - find_token_in_message.assert_called_once_with(self.msg) - take_action.assert_not_awaited() - - @autospec(TokenRemover, "find_token_in_message") - async def test_on_message_ignores_dms_bots(self, find_token_in_message): - """Shouldn't parse a message if it is a DM or authored by a bot.""" - cog = TokenRemover(self.bot) - dm_msg = MockMessage(guild=None) - bot_msg = MockMessage(author=MagicMock(bot=True)) - - for msg in (dm_msg, bot_msg): - await cog.on_message(msg) - find_token_in_message.assert_not_called() - - @autospec("bot.cogs.filters.token_remover", "TOKEN_RE") - def test_find_token_no_matches(self, token_re): - """None should be returned if the regex matches no tokens in a message.""" - token_re.finditer.return_value = () - - return_value = TokenRemover.find_token_in_message(self.msg) - - self.assertIsNone(return_value) - token_re.finditer.assert_called_once_with(self.msg.content) - - @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") - @autospec("bot.cogs.filters.token_remover", "Token") - @autospec("bot.cogs.filters.token_remover", "TOKEN_RE") - def test_find_token_valid_match(self, token_re, token_cls, is_valid_id, is_valid_timestamp): - """The first match with a valid user ID and timestamp should be returned as a `Token`.""" - matches = [ - mock.create_autospec(Match, spec_set=True, instance=True), - mock.create_autospec(Match, spec_set=True, instance=True), - ] - tokens = [ - mock.create_autospec(Token, spec_set=True, instance=True), - mock.create_autospec(Token, spec_set=True, instance=True), - ] - - token_re.finditer.return_value = matches - token_cls.side_effect = tokens - is_valid_id.side_effect = (False, True) # The 1st match will be invalid, 2nd one valid. - is_valid_timestamp.return_value = True - - return_value = TokenRemover.find_token_in_message(self.msg) - - self.assertEqual(tokens[1], return_value) - token_re.finditer.assert_called_once_with(self.msg.content) - - @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") - @autospec("bot.cogs.filters.token_remover", "Token") - @autospec("bot.cogs.filters.token_remover", "TOKEN_RE") - def test_find_token_invalid_matches(self, token_re, token_cls, is_valid_id, is_valid_timestamp): - """None should be returned if no matches have valid user IDs or timestamps.""" - token_re.finditer.return_value = [mock.create_autospec(Match, spec_set=True, instance=True)] - token_cls.return_value = mock.create_autospec(Token, spec_set=True, instance=True) - is_valid_id.return_value = False - is_valid_timestamp.return_value = False - - return_value = TokenRemover.find_token_in_message(self.msg) - - self.assertIsNone(return_value) - token_re.finditer.assert_called_once_with(self.msg.content) - - def test_regex_invalid_tokens(self): - """Messages without anything looking like a token are not matched.""" - tokens = ( - "", - "lemon wins", - "..", - "x.y", - "x.y.", - ".y.z", - ".y.", - "..z", - "x..z", - " . . ", - "\n.\n.\n", - "hellö.world.bye", - "base64.nötbåse64.morebase64", - "19jd3J.dfkm3d.€víł§tüff", - ) - - for token in tokens: - with self.subTest(token=token): - results = token_remover.TOKEN_RE.findall(token) - self.assertEqual(len(results), 0) - - def test_regex_valid_tokens(self): - """Messages that look like tokens should be matched.""" - # Don't worry, these tokens have been invalidated. - tokens = ( - "NDcyMjY1OTQzMDYy_DEzMz-y.XsyRkw.VXmErH7j511turNpfURmb0rVNm8", - "NDcyMjY1OTQzMDYyNDEzMzMy.Xrim9Q.Ysnu2wacjaKs7qnoo46S8Dm2us8", - "NDc1MDczNjI5Mzk5NTQ3OTA0.XsyR-w.sJf6omBPORBPju3WJEIAcwW9Zds", - "NDY3MjIzMjMwNjUwNzc3NjQx.XsySD_.s45jqDV_Iisn-symw0yDRrk_jf4", - ) - - for token in tokens: - with self.subTest(token=token): - results = token_remover.TOKEN_RE.fullmatch(token) - self.assertIsNotNone(results, f"{token} was not matched by the regex") - - def test_regex_matches_multiple_valid(self): - """Should support multiple matches in the middle of a string.""" - token_1 = "NDY3MjIzMjMwNjUwNzc3NjQx.XsyWGg.uFNEQPCc4ePwGh7egG8UicQssz8" - token_2 = "NDcyMjY1OTQzMDYyNDEzMzMy.XsyWMw.l8XPnDqb0lp-EiQ2g_0xVFT1pyc" - message = f"garbage {token_1} hello {token_2} world" - - results = token_remover.TOKEN_RE.finditer(message) - results = [match[0] for match in results] - self.assertCountEqual((token_1, token_2), results) - - @autospec("bot.cogs.filters.token_remover", "LOG_MESSAGE") - def test_format_log_message(self, log_message): - """Should correctly format the log message with info from the message and token.""" - token = Token("NDY3MjIzMjMwNjUwNzc3NjQx", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") - log_message.format.return_value = "Howdy" - - return_value = TokenRemover.format_log_message(self.msg, token) - - self.assertEqual(return_value, log_message.format.return_value) - log_message.format.assert_called_once_with( - author=self.msg.author, - author_id=self.msg.author.id, - channel=self.msg.channel.mention, - user_id=token.user_id, - timestamp=token.timestamp, - hmac="x" * len(token.hmac), - ) - - @mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock) - @autospec("bot.cogs.filters.token_remover", "log") - @autospec(TokenRemover, "format_log_message") - async def test_take_action(self, format_log_message, logger, mod_log_property): - """Should delete the message and send a mod log.""" - cog = TokenRemover(self.bot) - mod_log = mock.create_autospec(ModLog, spec_set=True, instance=True) - token = mock.create_autospec(Token, spec_set=True, instance=True) - log_msg = "testing123" - - mod_log_property.return_value = mod_log - format_log_message.return_value = log_msg - - await cog.take_action(self.msg, token) - - self.msg.delete.assert_called_once_with() - self.msg.channel.send.assert_called_once_with( - token_remover.DELETION_MESSAGE_TEMPLATE.format(mention=self.msg.author.mention) - ) - - format_log_message.assert_called_once_with(self.msg, token) - logger.debug.assert_called_with(log_msg) - self.bot.stats.incr.assert_called_once_with("tokens.removed_tokens") - - mod_log.ignore.assert_called_once_with(constants.Event.message_delete, self.msg.id) - mod_log.send_log_message.assert_called_once_with( - icon_url=constants.Icons.token_removed, - colour=Colour(constants.Colours.soft_red), - title="Token removed!", - text=log_msg, - thumbnail=self.msg.author.avatar_url_as.return_value, - channel_id=constants.Channels.mod_alerts - ) - - @mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock) - async def test_take_action_delete_failure(self, mod_log_property): - """Shouldn't send any messages if the token message can't be deleted.""" - cog = TokenRemover(self.bot) - mod_log_property.return_value = mock.create_autospec(ModLog, spec_set=True, instance=True) - self.msg.delete.side_effect = NotFound(MagicMock(), MagicMock()) - - token = mock.create_autospec(Token, spec_set=True, instance=True) - await cog.take_action(self.msg, token) - - self.msg.delete.assert_called_once_with() - self.msg.channel.send.assert_not_awaited() - - -class TokenRemoverExtensionTests(unittest.TestCase): - """Tests for the token_remover extension.""" - - @autospec("bot.cogs.filters.token_remover", "TokenRemover") - def test_extension_setup(self, cog): - """The TokenRemover cog should be added.""" - bot = MockBot() - token_remover.setup(bot) - - cog.assert_called_once_with(bot) - bot.add_cog.assert_called_once() - self.assertTrue(isinstance(bot.add_cog.call_args.args[0], TokenRemover)) diff --git a/tests/bot/cogs/info/__init__.py b/tests/bot/cogs/info/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/bot/cogs/info/test_information.py b/tests/bot/cogs/info/test_information.py deleted file mode 100644 index 895a8328e..000000000 --- a/tests/bot/cogs/info/test_information.py +++ /dev/null @@ -1,584 +0,0 @@ -import asyncio -import textwrap -import unittest -import unittest.mock - -import discord - -from bot import constants -from bot.cogs.info import information -from bot.utils.checks import InWhitelistCheckFailure -from tests import helpers - -COG_PATH = "bot.cogs.info.information.Information" - - -class InformationCogTests(unittest.TestCase): - """Tests the Information cog.""" - - @classmethod - def setUpClass(cls): - cls.moderator_role = helpers.MockRole(name="Moderator", id=constants.Roles.moderators) - - def setUp(self): - """Sets up fresh objects for each test.""" - self.bot = helpers.MockBot() - - self.cog = information.Information(self.bot) - - self.ctx = helpers.MockContext() - self.ctx.author.roles.append(self.moderator_role) - - def test_roles_command_command(self): - """Test if the `role_info` command correctly returns the `moderator_role`.""" - self.ctx.guild.roles.append(self.moderator_role) - - self.cog.roles_info.can_run = unittest.mock.AsyncMock() - self.cog.roles_info.can_run.return_value = True - - coroutine = self.cog.roles_info.callback(self.cog, self.ctx) - - self.assertIsNone(asyncio.run(coroutine)) - self.ctx.send.assert_called_once() - - _, kwargs = self.ctx.send.call_args - embed = kwargs.pop('embed') - - self.assertEqual(embed.title, "Role information (Total 1 role)") - self.assertEqual(embed.colour, discord.Colour.blurple()) - self.assertEqual(embed.description, f"\n`{self.moderator_role.id}` - {self.moderator_role.mention}\n") - - def test_role_info_command(self): - """Tests the `role info` command.""" - dummy_role = helpers.MockRole( - name="Dummy", - id=112233445566778899, - colour=discord.Colour.blurple(), - position=10, - members=[self.ctx.author], - permissions=discord.Permissions(0) - ) - - admin_role = helpers.MockRole( - name="Admins", - id=998877665544332211, - colour=discord.Colour.red(), - position=3, - members=[self.ctx.author], - permissions=discord.Permissions(0), - ) - - self.ctx.guild.roles.append([dummy_role, admin_role]) - - self.cog.role_info.can_run = unittest.mock.AsyncMock() - self.cog.role_info.can_run.return_value = True - - coroutine = self.cog.role_info.callback(self.cog, self.ctx, dummy_role, admin_role) - - self.assertIsNone(asyncio.run(coroutine)) - - self.assertEqual(self.ctx.send.call_count, 2) - - (_, dummy_kwargs), (_, admin_kwargs) = self.ctx.send.call_args_list - - dummy_embed = dummy_kwargs["embed"] - admin_embed = admin_kwargs["embed"] - - self.assertEqual(dummy_embed.title, "Dummy info") - self.assertEqual(dummy_embed.colour, discord.Colour.blurple()) - - self.assertEqual(dummy_embed.fields[0].value, str(dummy_role.id)) - self.assertEqual(dummy_embed.fields[1].value, f"#{dummy_role.colour.value:0>6x}") - self.assertEqual(dummy_embed.fields[2].value, "0.63 0.48 218") - self.assertEqual(dummy_embed.fields[3].value, "1") - self.assertEqual(dummy_embed.fields[4].value, "10") - self.assertEqual(dummy_embed.fields[5].value, "0") - - self.assertEqual(admin_embed.title, "Admins info") - self.assertEqual(admin_embed.colour, discord.Colour.red()) - - @unittest.mock.patch('bot.cogs.info.information.time_since') - def test_server_info_command(self, time_since_patch): - time_since_patch.return_value = '2 days ago' - - self.ctx.guild = helpers.MockGuild( - features=('lemons', 'apples'), - region="The Moon", - roles=[self.moderator_role], - channels=[ - discord.TextChannel( - state={}, - guild=self.ctx.guild, - data={'id': 42, 'name': 'lemons-offering', 'position': 22, 'type': 'text'} - ), - discord.CategoryChannel( - state={}, - guild=self.ctx.guild, - data={'id': 5125, 'name': 'the-lemon-collection', 'position': 22, 'type': 'category'} - ), - discord.VoiceChannel( - state={}, - guild=self.ctx.guild, - data={'id': 15290, 'name': 'listen-to-lemon', 'position': 22, 'type': 'voice'} - ) - ], - members=[ - *(helpers.MockMember(status=discord.Status.online) for _ in range(2)), - *(helpers.MockMember(status=discord.Status.idle) for _ in range(1)), - *(helpers.MockMember(status=discord.Status.dnd) for _ in range(4)), - *(helpers.MockMember(status=discord.Status.offline) for _ in range(3)), - ], - member_count=1_234, - icon_url='a-lemon.jpg', - ) - - coroutine = self.cog.server_info.callback(self.cog, self.ctx) - self.assertIsNone(asyncio.run(coroutine)) - - time_since_patch.assert_called_once_with(self.ctx.guild.created_at, precision='days') - _, kwargs = self.ctx.send.call_args - embed = kwargs.pop('embed') - self.assertEqual(embed.colour, discord.Colour.blurple()) - self.assertEqual( - embed.description, - textwrap.dedent( - f""" - **Server information** - Created: {time_since_patch.return_value} - Voice region: {self.ctx.guild.region} - Features: {', '.join(self.ctx.guild.features)} - - **Channel counts** - Category channels: 1 - Text channels: 1 - Voice channels: 1 - Staff channels: 0 - - **Member counts** - Members: {self.ctx.guild.member_count:,} - Staff members: 0 - Roles: {len(self.ctx.guild.roles)} - - **Member statuses** - {constants.Emojis.status_online} 2 - {constants.Emojis.status_idle} 1 - {constants.Emojis.status_dnd} 4 - {constants.Emojis.status_offline} 3 - """ - ) - ) - self.assertEqual(embed.thumbnail.url, 'a-lemon.jpg') - - -class UserInfractionHelperMethodTests(unittest.TestCase): - """Tests for the helper methods of the `!user` command.""" - - def setUp(self): - """Common set-up steps done before for each test.""" - self.bot = helpers.MockBot() - self.bot.api_client.get = unittest.mock.AsyncMock() - self.cog = information.Information(self.bot) - self.member = helpers.MockMember(id=1234) - - def test_user_command_helper_method_get_requests(self): - """The helper methods should form the correct get requests.""" - test_values = ( - { - "helper_method": self.cog.basic_user_infraction_counts, - "expected_args": ("bot/infractions", {'hidden': 'False', 'user__id': str(self.member.id)}), - }, - { - "helper_method": self.cog.expanded_user_infraction_counts, - "expected_args": ("bot/infractions", {'user__id': str(self.member.id)}), - }, - { - "helper_method": self.cog.user_nomination_counts, - "expected_args": ("bot/nominations", {'user__id': str(self.member.id)}), - }, - ) - - for test_value in test_values: - helper_method = test_value["helper_method"] - endpoint, params = test_value["expected_args"] - - with self.subTest(method=helper_method, endpoint=endpoint, params=params): - asyncio.run(helper_method(self.member)) - self.bot.api_client.get.assert_called_once_with(endpoint, params=params) - self.bot.api_client.get.reset_mock() - - def _method_subtests(self, method, test_values, default_header): - """Helper method that runs the subtests for the different helper methods.""" - for test_value in test_values: - api_response = test_value["api response"] - expected_lines = test_value["expected_lines"] - - with self.subTest(method=method, api_response=api_response, expected_lines=expected_lines): - self.bot.api_client.get.return_value = api_response - - expected_output = "\n".join(default_header + expected_lines) - actual_output = asyncio.run(method(self.member)) - - self.assertEqual(expected_output, actual_output) - - def test_basic_user_infraction_counts_returns_correct_strings(self): - """The method should correctly list both the total and active number of non-hidden infractions.""" - test_values = ( - # No infractions means zero counts - { - "api response": [], - "expected_lines": ["Total: 0", "Active: 0"], - }, - # Simple, single-infraction dictionaries - { - "api response": [{"type": "ban", "active": True}], - "expected_lines": ["Total: 1", "Active: 1"], - }, - { - "api response": [{"type": "ban", "active": False}], - "expected_lines": ["Total: 1", "Active: 0"], - }, - # Multiple infractions with various `active` status - { - "api response": [ - {"type": "ban", "active": True}, - {"type": "kick", "active": False}, - {"type": "ban", "active": True}, - {"type": "ban", "active": False}, - ], - "expected_lines": ["Total: 4", "Active: 2"], - }, - ) - - header = ["**Infractions**"] - - self._method_subtests(self.cog.basic_user_infraction_counts, test_values, header) - - def test_expanded_user_infraction_counts_returns_correct_strings(self): - """The method should correctly list the total and active number of all infractions split by infraction type.""" - test_values = ( - { - "api response": [], - "expected_lines": ["This user has never received an infraction."], - }, - # Shows non-hidden inactive infraction as expected - { - "api response": [{"type": "kick", "active": False, "hidden": False}], - "expected_lines": ["Kicks: 1"], - }, - # Shows non-hidden active infraction as expected - { - "api response": [{"type": "mute", "active": True, "hidden": False}], - "expected_lines": ["Mutes: 1 (1 active)"], - }, - # Shows hidden inactive infraction as expected - { - "api response": [{"type": "superstar", "active": False, "hidden": True}], - "expected_lines": ["Superstars: 1"], - }, - # Shows hidden active infraction as expected - { - "api response": [{"type": "ban", "active": True, "hidden": True}], - "expected_lines": ["Bans: 1 (1 active)"], - }, - # Correctly displays tally of multiple infractions of mixed properties in alphabetical order - { - "api response": [ - {"type": "kick", "active": False, "hidden": True}, - {"type": "ban", "active": True, "hidden": True}, - {"type": "superstar", "active": True, "hidden": True}, - {"type": "mute", "active": True, "hidden": True}, - {"type": "ban", "active": False, "hidden": False}, - {"type": "note", "active": False, "hidden": True}, - {"type": "note", "active": False, "hidden": True}, - {"type": "warn", "active": False, "hidden": False}, - {"type": "note", "active": False, "hidden": True}, - ], - "expected_lines": [ - "Bans: 2 (1 active)", - "Kicks: 1", - "Mutes: 1 (1 active)", - "Notes: 3", - "Superstars: 1 (1 active)", - "Warns: 1", - ], - }, - ) - - header = ["**Infractions**"] - - self._method_subtests(self.cog.expanded_user_infraction_counts, test_values, header) - - def test_user_nomination_counts_returns_correct_strings(self): - """The method should list the number of active and historical nominations for the user.""" - test_values = ( - { - "api response": [], - "expected_lines": ["This user has never been nominated."], - }, - { - "api response": [{'active': True}], - "expected_lines": ["This user is **currently** nominated (1 nomination in total)."], - }, - { - "api response": [{'active': True}, {'active': False}], - "expected_lines": ["This user is **currently** nominated (2 nominations in total)."], - }, - { - "api response": [{'active': False}], - "expected_lines": ["This user has 1 historical nomination, but is currently not nominated."], - }, - { - "api response": [{'active': False}, {'active': False}], - "expected_lines": ["This user has 2 historical nominations, but is currently not nominated."], - }, - - ) - - header = ["**Nominations**"] - - self._method_subtests(self.cog.user_nomination_counts, test_values, header) - - -@unittest.mock.patch("bot.cogs.info.information.time_since", new=unittest.mock.MagicMock(return_value="1 year ago")) -@unittest.mock.patch("bot.cogs.info.information.constants.MODERATION_CHANNELS", new=[50]) -class UserEmbedTests(unittest.TestCase): - """Tests for the creation of the `!user` embed.""" - - def setUp(self): - """Common set-up steps done before for each test.""" - self.bot = helpers.MockBot() - self.bot.api_client.get = unittest.mock.AsyncMock() - self.cog = information.Information(self.bot) - - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) - def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self): - """The embed should use the string representation of the user if they don't have a nick.""" - ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) - user = helpers.MockMember() - user.nick = None - user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") - - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) - - self.assertEqual(embed.title, "Mr. Hemlock") - - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) - def test_create_user_embed_uses_nick_in_title_if_available(self): - """The embed should use the nick if it's available.""" - ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) - user = helpers.MockMember() - user.nick = "Cat lover" - user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") - - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) - - self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)") - - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) - def test_create_user_embed_ignores_everyone_role(self): - """Created `!user` embeds should not contain mention of the @everyone-role.""" - ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) - admins_role = helpers.MockRole(name='Admins') - admins_role.colour = 100 - - # A `MockMember` has the @Everyone role by default; we add the Admins to that. - user = helpers.MockMember(roles=[admins_role], top_role=admins_role) - - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) - - self.assertIn("&Admins", embed.description) - self.assertNotIn("&Everyone", embed.description) - - @unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=unittest.mock.AsyncMock) - @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=unittest.mock.AsyncMock) - def test_create_user_embed_expanded_information_in_moderation_channels(self, nomination_counts, infraction_counts): - """The embed should contain expanded infractions and nomination info in mod channels.""" - ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=50)) - - moderators_role = helpers.MockRole(name='Moderators') - moderators_role.colour = 100 - - infraction_counts.return_value = "expanded infractions info" - nomination_counts.return_value = "nomination info" - - user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) - - infraction_counts.assert_called_once_with(user) - nomination_counts.assert_called_once_with(user) - - self.assertEqual( - textwrap.dedent(f""" - **User Information** - Created: {"1 year ago"} - Profile: {user.mention} - ID: {user.id} - - **Member Information** - Joined: {"1 year ago"} - Roles: &Moderators - - expanded infractions info - - nomination info - """).strip(), - embed.description - ) - - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=unittest.mock.AsyncMock) - def test_create_user_embed_basic_information_outside_of_moderation_channels(self, infraction_counts): - """The embed should contain only basic infraction data outside of mod channels.""" - ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=100)) - - moderators_role = helpers.MockRole(name='Moderators') - moderators_role.colour = 100 - - infraction_counts.return_value = "basic infractions info" - - user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) - - infraction_counts.assert_called_once_with(user) - - self.assertEqual( - textwrap.dedent(f""" - **User Information** - Created: {"1 year ago"} - Profile: {user.mention} - ID: {user.id} - - **Member Information** - Joined: {"1 year ago"} - Roles: &Moderators - - basic infractions info - """).strip(), - embed.description - ) - - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) - def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self): - """The embed should be created with the colour of the top role, if a top role is available.""" - ctx = helpers.MockContext() - - moderators_role = helpers.MockRole(name='Moderators') - moderators_role.colour = 100 - - user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) - - self.assertEqual(embed.colour, discord.Colour(moderators_role.colour)) - - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) - def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self): - """The embed should be created with a blurple colour if the user has no assigned roles.""" - ctx = helpers.MockContext() - - user = helpers.MockMember(id=217) - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) - - self.assertEqual(embed.colour, discord.Colour.blurple()) - - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) - def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self): - """The embed thumbnail should be set to the user's avatar in `png` format.""" - ctx = helpers.MockContext() - - user = helpers.MockMember(id=217) - user.avatar_url_as.return_value = "avatar url" - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) - - user.avatar_url_as.assert_called_once_with(static_format="png") - self.assertEqual(embed.thumbnail.url, "avatar url") - - -@unittest.mock.patch("bot.cogs.info.information.constants") -class UserCommandTests(unittest.TestCase): - """Tests for the `!user` command.""" - - def setUp(self): - """Set up steps executed before each test is run.""" - self.bot = helpers.MockBot() - self.cog = information.Information(self.bot) - - self.moderator_role = helpers.MockRole(name="Moderators", id=2, position=10) - self.flautist_role = helpers.MockRole(name="Flautists", id=3, position=2) - self.bassist_role = helpers.MockRole(name="Bassists", id=4, position=3) - - self.author = helpers.MockMember(id=1, name="syntaxaire") - self.moderator = helpers.MockMember(id=2, name="riffautae", roles=[self.moderator_role]) - self.target = helpers.MockMember(id=3, name="__fluzz__") - - def test_regular_member_cannot_target_another_member(self, constants): - """A regular user should not be able to use `!user` targeting another user.""" - constants.MODERATION_ROLES = [self.moderator_role.id] - - ctx = helpers.MockContext(author=self.author) - - asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target)) - - ctx.send.assert_called_once_with("You may not use this command on users other than yourself.") - - def test_regular_member_cannot_use_command_outside_of_bot_commands(self, constants): - """A regular user should not be able to use this command outside of bot-commands.""" - constants.MODERATION_ROLES = [self.moderator_role.id] - constants.STAFF_ROLES = [self.moderator_role.id] - constants.Channels.bot_commands = 50 - - ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=100)) - - msg = "Sorry, but you may only use this command within <#50>." - with self.assertRaises(InWhitelistCheckFailure, msg=msg): - asyncio.run(self.cog.user_info.callback(self.cog, ctx)) - - @unittest.mock.patch("bot.cogs.info.information.Information.create_user_embed") - def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants): - """A regular user should be allowed to use `!user` targeting themselves in bot-commands.""" - constants.STAFF_ROLES = [self.moderator_role.id] - constants.Channels.bot_commands = 50 - - ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) - - asyncio.run(self.cog.user_info.callback(self.cog, ctx)) - - create_embed.assert_called_once_with(ctx, self.author) - ctx.send.assert_called_once() - - @unittest.mock.patch("bot.cogs.info.information.Information.create_user_embed") - def test_regular_user_can_explicitly_target_themselves(self, create_embed, constants): - """A user should target itself with `!user` when a `user` argument was not provided.""" - constants.STAFF_ROLES = [self.moderator_role.id] - constants.Channels.bot_commands = 50 - - ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) - - asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.author)) - - create_embed.assert_called_once_with(ctx, self.author) - ctx.send.assert_called_once() - - @unittest.mock.patch("bot.cogs.info.information.Information.create_user_embed") - def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants): - """Staff members should be able to bypass the bot-commands channel restriction.""" - constants.STAFF_ROLES = [self.moderator_role.id] - constants.Channels.bot_commands = 50 - - ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=200)) - - asyncio.run(self.cog.user_info.callback(self.cog, ctx)) - - create_embed.assert_called_once_with(ctx, self.moderator) - ctx.send.assert_called_once() - - @unittest.mock.patch("bot.cogs.info.information.Information.create_user_embed") - def test_moderators_can_target_another_member(self, create_embed, constants): - """A moderator should be able to use `!user` targeting another user.""" - constants.MODERATION_ROLES = [self.moderator_role.id] - constants.STAFF_ROLES = [self.moderator_role.id] - - ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=50)) - - asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target)) - - create_embed.assert_called_once_with(ctx, self.target) - ctx.send.assert_called_once() diff --git a/tests/bot/cogs/moderation/__init__.py b/tests/bot/cogs/moderation/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/bot/cogs/moderation/infraction/__init__.py b/tests/bot/cogs/moderation/infraction/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/bot/cogs/moderation/infraction/test_infractions.py b/tests/bot/cogs/moderation/infraction/test_infractions.py deleted file mode 100644 index 2df61d431..000000000 --- a/tests/bot/cogs/moderation/infraction/test_infractions.py +++ /dev/null @@ -1,55 +0,0 @@ -import textwrap -import unittest -from unittest.mock import AsyncMock, Mock, patch - -from bot.cogs.moderation.infraction.infractions import Infractions -from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole - - -class TruncationTests(unittest.IsolatedAsyncioTestCase): - """Tests for ban and kick command reason truncation.""" - - def setUp(self): - self.bot = MockBot() - self.cog = Infractions(self.bot) - self.user = MockMember(id=1234, top_role=MockRole(id=3577, position=10)) - self.target = MockMember(id=1265, top_role=MockRole(id=9876, position=0)) - self.guild = MockGuild(id=4567) - self.ctx = MockContext(bot=self.bot, author=self.user, guild=self.guild) - - @patch("bot.cogs.moderation.infraction._utils.get_active_infraction") - @patch("bot.cogs.moderation.infraction._utils.post_infraction") - async def test_apply_ban_reason_truncation(self, post_infraction_mock, get_active_mock): - """Should truncate reason for `ctx.guild.ban`.""" - get_active_mock.return_value = None - post_infraction_mock.return_value = {"foo": "bar"} - - self.cog.apply_infraction = AsyncMock() - self.bot.get_cog.return_value = AsyncMock() - self.cog.mod_log.ignore = Mock() - self.ctx.guild.ban = Mock() - - await self.cog.apply_ban(self.ctx, self.target, "foo bar" * 3000) - self.ctx.guild.ban.assert_called_once_with( - self.target, - reason=textwrap.shorten("foo bar" * 3000, 512, placeholder="..."), - delete_message_days=0 - ) - self.cog.apply_infraction.assert_awaited_once_with( - self.ctx, {"foo": "bar"}, self.target, self.ctx.guild.ban.return_value - ) - - @patch("bot.cogs.moderation.infraction._utils.post_infraction") - async def test_apply_kick_reason_truncation(self, post_infraction_mock): - """Should truncate reason for `Member.kick`.""" - post_infraction_mock.return_value = {"foo": "bar"} - - self.cog.apply_infraction = AsyncMock() - self.cog.mod_log.ignore = Mock() - self.target.kick = Mock() - - await self.cog.apply_kick(self.ctx, self.target, "foo bar" * 3000) - self.target.kick.assert_called_once_with(reason=textwrap.shorten("foo bar" * 3000, 512, placeholder="...")) - self.cog.apply_infraction.assert_awaited_once_with( - self.ctx, {"foo": "bar"}, self.target, self.target.kick.return_value - ) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py deleted file mode 100644 index 5e4d90251..000000000 --- a/tests/bot/cogs/moderation/test_incidents.py +++ /dev/null @@ -1,770 +0,0 @@ -import asyncio -import enum -import logging -import typing as t -import unittest -from unittest.mock import AsyncMock, MagicMock, call, patch - -import aiohttp -import discord - -from bot.cogs.moderation import incidents -from bot.constants import Colours -from tests.helpers import ( - MockAsyncWebhook, - MockAttachment, - MockBot, - MockMember, - MockMessage, - MockReaction, - MockRole, - MockTextChannel, - MockUser, -) - - -class MockAsyncIterable: - """ - Helper for mocking asynchronous for loops. - - It does not appear that the `unittest` library currently provides anything that would - allow us to simply mock an async iterator, such as `discord.TextChannel.history`. - - We therefore write our own helper to wrap a regular synchronous iterable, and feed - its values via `__anext__` rather than `__next__`. - - This class was written for the purposes of testing the `Incidents` cog - it may not - be generic enough to be placed in the `tests.helpers` module. - """ - - def __init__(self, messages: t.Iterable): - """Take a sync iterable to be wrapped.""" - self.iter_messages = iter(messages) - - def __aiter__(self): - """Return `self` as we provide the `__anext__` method.""" - return self - - async def __anext__(self): - """ - Feed the next item, or raise `StopAsyncIteration`. - - Since we're wrapping a sync iterator, it will communicate that it has been depleted - by raising a `StopIteration`. The `async for` construct does not expect it, and we - therefore need to substitute it for the appropriate exception type. - """ - try: - return next(self.iter_messages) - except StopIteration: - raise StopAsyncIteration - - -class MockSignal(enum.Enum): - A = "A" - B = "B" - - -mock_404 = discord.NotFound( - response=MagicMock(aiohttp.ClientResponse), # Mock the erroneous response - message="Not found", -) - - -class TestDownloadFile(unittest.IsolatedAsyncioTestCase): - """Collection of tests for the `download_file` helper function.""" - - async def test_download_file_success(self): - """If `to_file` succeeds, function returns the acquired `discord.File`.""" - file = MagicMock(discord.File, filename="bigbadlemon.jpg") - attachment = MockAttachment(to_file=AsyncMock(return_value=file)) - - acquired_file = await incidents.download_file(attachment) - self.assertIs(file, acquired_file) - - async def test_download_file_404(self): - """If `to_file` encounters a 404, function handles the exception & returns None.""" - attachment = MockAttachment(to_file=AsyncMock(side_effect=mock_404)) - - acquired_file = await incidents.download_file(attachment) - self.assertIsNone(acquired_file) - - async def test_download_file_fail(self): - """If `to_file` fails on a non-404 error, function logs the exception & returns None.""" - arbitrary_error = discord.HTTPException(MagicMock(aiohttp.ClientResponse), "Arbitrary API error") - attachment = MockAttachment(to_file=AsyncMock(side_effect=arbitrary_error)) - - with self.assertLogs(logger=incidents.log, level=logging.ERROR): - acquired_file = await incidents.download_file(attachment) - - self.assertIsNone(acquired_file) - - -class TestMakeEmbed(unittest.IsolatedAsyncioTestCase): - """Collection of tests for the `make_embed` helper function.""" - - async def test_make_embed_actioned(self): - """Embed is coloured green and footer contains 'Actioned' when `outcome=Signal.ACTIONED`.""" - embed, file = await incidents.make_embed(MockMessage(), incidents.Signal.ACTIONED, MockMember()) - - self.assertEqual(embed.colour.value, Colours.soft_green) - self.assertIn("Actioned", embed.footer.text) - - async def test_make_embed_not_actioned(self): - """Embed is coloured red and footer contains 'Rejected' when `outcome=Signal.NOT_ACTIONED`.""" - embed, file = await incidents.make_embed(MockMessage(), incidents.Signal.NOT_ACTIONED, MockMember()) - - self.assertEqual(embed.colour.value, Colours.soft_red) - self.assertIn("Rejected", embed.footer.text) - - async def test_make_embed_content(self): - """Incident content appears as embed description.""" - incident = MockMessage(content="this is an incident") - embed, file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember()) - - self.assertEqual(incident.content, embed.description) - - async def test_make_embed_with_attachment_succeeds(self): - """Incident's attachment is downloaded and displayed in the embed's image field.""" - file = MagicMock(discord.File, filename="bigbadjoe.jpg") - attachment = MockAttachment(filename="bigbadjoe.jpg") - incident = MockMessage(content="this is an incident", attachments=[attachment]) - - # Patch `download_file` to return our `file` - with patch("bot.cogs.moderation.incidents.download_file", AsyncMock(return_value=file)): - embed, returned_file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember()) - - self.assertIs(file, returned_file) - self.assertEqual("attachment://bigbadjoe.jpg", embed.image.url) - - async def test_make_embed_with_attachment_fails(self): - """Incident's attachment fails to download, proxy url is linked instead.""" - attachment = MockAttachment(proxy_url="discord.com/bigbadjoe.jpg") - incident = MockMessage(content="this is an incident", attachments=[attachment]) - - # Patch `download_file` to return None as if the download failed - with patch("bot.cogs.moderation.incidents.download_file", AsyncMock(return_value=None)): - embed, returned_file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember()) - - self.assertIsNone(returned_file) - - # The author name field is simply expected to have something in it, we do not assert the message - self.assertGreater(len(embed.author.name), 0) - self.assertEqual(embed.author.url, "discord.com/bigbadjoe.jpg") # However, it should link the exact url - - -@patch("bot.constants.Channels.incidents", 123) -class TestIsIncident(unittest.TestCase): - """ - Collection of tests for the `is_incident` helper function. - - In `setUp`, we will create a mock message which should qualify as an incident. Each - test case will then mutate this instance to make it **not** qualify, in various ways. - - Notice that we patch the #incidents channel id globally for this class. - """ - - def setUp(self) -> None: - """Prepare a mock message which should qualify as an incident.""" - self.incident = MockMessage( - channel=MockTextChannel(id=123), - content="this is an incident", - author=MockUser(bot=False), - pinned=False, - ) - - def test_is_incident_true(self): - """Message qualifies as an incident if unchanged.""" - self.assertTrue(incidents.is_incident(self.incident)) - - def check_false(self): - """Assert that `self.incident` does **not** qualify as an incident.""" - self.assertFalse(incidents.is_incident(self.incident)) - - def test_is_incident_false_channel(self): - """Message doesn't qualify if sent outside of #incidents.""" - self.incident.channel = MockTextChannel(id=456) - self.check_false() - - def test_is_incident_false_content(self): - """Message doesn't qualify if content begins with hash symbol.""" - self.incident.content = "# this is a comment message" - self.check_false() - - def test_is_incident_false_author(self): - """Message doesn't qualify if author is a bot.""" - self.incident.author = MockUser(bot=True) - self.check_false() - - def test_is_incident_false_pinned(self): - """Message doesn't qualify if it is pinned.""" - self.incident.pinned = True - self.check_false() - - -class TestOwnReactions(unittest.TestCase): - """Assertions for the `own_reactions` function.""" - - def test_own_reactions(self): - """Only bot's own emoji are extracted from the input incident.""" - reactions = ( - MockReaction(emoji="A", me=True), - MockReaction(emoji="B", me=True), - MockReaction(emoji="C", me=False), - ) - message = MockMessage(reactions=reactions) - self.assertSetEqual(incidents.own_reactions(message), {"A", "B"}) - - -@patch("bot.cogs.moderation.incidents.ALL_SIGNALS", {"A", "B"}) -class TestHasSignals(unittest.TestCase): - """ - Assertions for the `has_signals` function. - - We patch `ALL_SIGNALS` globally. Each test function then patches `own_reactions` - as appropriate. - """ - - def test_has_signals_true(self): - """True when `own_reactions` returns all emoji in `ALL_SIGNALS`.""" - message = MockMessage() - own_reactions = MagicMock(return_value={"A", "B"}) - - with patch("bot.cogs.moderation.incidents.own_reactions", own_reactions): - self.assertTrue(incidents.has_signals(message)) - - def test_has_signals_false(self): - """False when `own_reactions` does not return all emoji in `ALL_SIGNALS`.""" - message = MockMessage() - own_reactions = MagicMock(return_value={"A", "C"}) - - with patch("bot.cogs.moderation.incidents.own_reactions", own_reactions): - self.assertFalse(incidents.has_signals(message)) - - -@patch("bot.cogs.moderation.incidents.Signal", MockSignal) -class TestAddSignals(unittest.IsolatedAsyncioTestCase): - """ - Assertions for the `add_signals` coroutine. - - These are all fairly similar and could go into a single test function, but I found the - patching & sub-testing fairly awkward in that case and decided to split them up - to avoid unnecessary syntax noise. - """ - - def setUp(self): - """Prepare a mock incident message for tests to use.""" - self.incident = MockMessage() - - @patch("bot.cogs.moderation.incidents.own_reactions", MagicMock(return_value=set())) - async def test_add_signals_missing(self): - """All emoji are added when none are present.""" - await incidents.add_signals(self.incident) - self.incident.add_reaction.assert_has_calls([call("A"), call("B")]) - - @patch("bot.cogs.moderation.incidents.own_reactions", MagicMock(return_value={"A"})) - async def test_add_signals_partial(self): - """Only missing emoji are added when some are present.""" - await incidents.add_signals(self.incident) - self.incident.add_reaction.assert_has_calls([call("B")]) - - @patch("bot.cogs.moderation.incidents.own_reactions", MagicMock(return_value={"A", "B"})) - async def test_add_signals_present(self): - """No emoji are added when all are present.""" - await incidents.add_signals(self.incident) - self.incident.add_reaction.assert_not_called() - - -class TestIncidents(unittest.IsolatedAsyncioTestCase): - """ - Tests for bound methods of the `Incidents` cog. - - Use this as a base class for `Incidents` tests - it will prepare a fresh instance - for each test function, but not make any assertions on its own. Tests can mutate - the instance as they wish. - """ - - def setUp(self): - """ - Prepare a fresh `Incidents` instance for each test. - - Note that this will not schedule `crawl_incidents` in the background, as everything - is being mocked. The `crawl_task` attribute will end up being None. - """ - self.cog_instance = incidents.Incidents(MockBot()) - - -@patch("asyncio.sleep", AsyncMock()) # Prevent the coro from sleeping to speed up the test -class TestCrawlIncidents(TestIncidents): - """ - Tests for the `Incidents.crawl_incidents` coroutine. - - Apart from `test_crawl_incidents_waits_until_cache_ready`, all tests in this class - will patch the return values of `is_incident` and `has_signal` and then observe - whether the `AsyncMock` for `add_signals` was awaited or not. - - The `add_signals` mock is added by each test separately to ensure it is clean (has not - been awaited by another test yet). The mock can be reset, but this appears to be the - cleaner way. - - For each test, we inject a mock channel with a history of 1 message only (see: `setUp`). - """ - - def setUp(self): - """For each test, ensure `bot.get_channel` returns a channel with 1 arbitrary message.""" - super().setUp() # First ensure we get `cog_instance` from parent - - incidents_history = MagicMock(return_value=MockAsyncIterable([MockMessage()])) - self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(history=incidents_history)) - - async def test_crawl_incidents_waits_until_cache_ready(self): - """ - The coroutine will await the `wait_until_guild_available` event. - - Since this task is schedule in the `__init__`, it is critical that it waits for the - cache to be ready, so that it can safely get the #incidents channel. - """ - await self.cog_instance.crawl_incidents() - self.cog_instance.bot.wait_until_guild_available.assert_awaited() - - @patch("bot.cogs.moderation.incidents.add_signals", AsyncMock()) - @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=False)) # Message doesn't qualify - @patch("bot.cogs.moderation.incidents.has_signals", MagicMock(return_value=False)) - async def test_crawl_incidents_noop_if_is_not_incident(self): - """Signals are not added for a non-incident message.""" - await self.cog_instance.crawl_incidents() - incidents.add_signals.assert_not_awaited() - - @patch("bot.cogs.moderation.incidents.add_signals", AsyncMock()) - @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=True)) # Message qualifies - @patch("bot.cogs.moderation.incidents.has_signals", MagicMock(return_value=True)) # But already has signals - async def test_crawl_incidents_noop_if_message_already_has_signals(self): - """Signals are not added for messages which already have them.""" - await self.cog_instance.crawl_incidents() - incidents.add_signals.assert_not_awaited() - - @patch("bot.cogs.moderation.incidents.add_signals", AsyncMock()) - @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=True)) # Message qualifies - @patch("bot.cogs.moderation.incidents.has_signals", MagicMock(return_value=False)) # And doesn't have signals - async def test_crawl_incidents_add_signals_called(self): - """Message has signals added as it does not have them yet and qualifies as an incident.""" - await self.cog_instance.crawl_incidents() - incidents.add_signals.assert_awaited_once() - - -class TestArchive(TestIncidents): - """Tests for the `Incidents.archive` coroutine.""" - - async def test_archive_webhook_not_found(self): - """ - Method recovers and returns False when the webhook is not found. - - Implicitly, this also tests that the error is handled internally and doesn't - propagate out of the method, which is just as important. - """ - self.cog_instance.bot.fetch_webhook = AsyncMock(side_effect=mock_404) - self.assertFalse( - await self.cog_instance.archive(incident=MockMessage(), outcome=MagicMock(), actioned_by=MockMember()) - ) - - async def test_archive_relays_incident(self): - """ - If webhook is found, method relays `incident` properly. - - This test will assert that the fetched webhook's `send` method is fed the correct arguments, - and that the `archive` method returns True. - """ - webhook = MockAsyncWebhook() - self.cog_instance.bot.fetch_webhook = AsyncMock(return_value=webhook) # Patch in our webhook - - # Define our own `incident` to be archived - incident = MockMessage( - content="this is an incident", - author=MockUser(name="author_name", avatar_url="author_avatar"), - id=123, - ) - built_embed = MagicMock(discord.Embed, id=123) # We patch `make_embed` to return this - - with patch("bot.cogs.moderation.incidents.make_embed", AsyncMock(return_value=(built_embed, None))): - archive_return = await self.cog_instance.archive(incident, MagicMock(value="A"), MockMember()) - - # Now we check that the webhook was given the correct args, and that `archive` returned True - webhook.send.assert_called_once_with( - embed=built_embed, - username="author_name", - avatar_url="author_avatar", - file=None, - ) - self.assertTrue(archive_return) - - async def test_archive_clyde_username(self): - """ - The archive webhook username is cleansed using `sub_clyde`. - - Discord will reject any webhook with "clyde" in the username field, as it impersonates - the official Clyde bot. Since we do not control what the username will be (the incident - author name is used), we must ensure the name is cleansed, otherwise the relay may fail. - - This test assumes the username is passed as a kwarg. If this test fails, please review - whether the passed argument is being retrieved correctly. - """ - webhook = MockAsyncWebhook() - self.cog_instance.bot.fetch_webhook = AsyncMock(return_value=webhook) - - message_from_clyde = MockMessage(author=MockUser(name="clyde the great")) - await self.cog_instance.archive(message_from_clyde, MagicMock(incidents.Signal), MockMember()) - - self.assertNotIn("clyde", webhook.send.call_args.kwargs["username"]) - - -class TestMakeConfirmationTask(TestIncidents): - """ - Tests for the `Incidents.make_confirmation_task` method. - - Writing tests for this method is difficult, as it mostly just delegates the provided - information elsewhere. There is very little internal logic. Whether our approach - works conceptually is difficult to prove using unit tests. - """ - - def test_make_confirmation_task_check(self): - """ - The internal check will recognize the passed incident. - - This is a little tricky - we first pass a message with a specific `id` in, and then - retrieve the built check from the `call_args` of the `wait_for` method. This relies - on the check being passed as a kwarg. - - Once the check is retrieved, we assert that it gives True for our incident's `id`, - and False for any other. - - If this function begins to fail, first check that `created_check` is being retrieved - correctly. It should be the function that is built locally in the tested method. - """ - self.cog_instance.make_confirmation_task(MockMessage(id=123)) - - self.cog_instance.bot.wait_for.assert_called_once() - created_check = self.cog_instance.bot.wait_for.call_args.kwargs["check"] - - # The `message_id` matches the `id` of our incident - self.assertTrue(created_check(payload=MagicMock(message_id=123))) - - # This `message_id` does not match - self.assertFalse(created_check(payload=MagicMock(message_id=0))) - - -@patch("bot.cogs.moderation.incidents.ALLOWED_ROLES", {1, 2}) -@patch("bot.cogs.moderation.incidents.Incidents.make_confirmation_task", AsyncMock()) # Generic awaitable -class TestProcessEvent(TestIncidents): - """Tests for the `Incidents.process_event` coroutine.""" - - async def test_process_event_bad_role(self): - """The reaction is removed when the author lacks all allowed roles.""" - incident = MockMessage() - member = MockMember(roles=[MockRole(id=0)]) # Must have role 1 or 2 - - await self.cog_instance.process_event("reaction", incident, member) - incident.remove_reaction.assert_called_once_with("reaction", member) - - async def test_process_event_bad_emoji(self): - """ - The reaction is removed when an invalid emoji is used. - - This requires that we pass in a `member` with valid roles, as we need the role check - to succeed. - """ - incident = MockMessage() - member = MockMember(roles=[MockRole(id=1)]) # Member has allowed role - - await self.cog_instance.process_event("invalid_signal", incident, member) - incident.remove_reaction.assert_called_once_with("invalid_signal", member) - - async def test_process_event_no_archive_on_investigating(self): - """Message is not archived on `Signal.INVESTIGATING`.""" - with patch("bot.cogs.moderation.incidents.Incidents.archive", AsyncMock()) as mocked_archive: - await self.cog_instance.process_event( - reaction=incidents.Signal.INVESTIGATING.value, - incident=MockMessage(), - member=MockMember(roles=[MockRole(id=1)]), - ) - - mocked_archive.assert_not_called() - - async def test_process_event_no_delete_if_archive_fails(self): - """ - Original message is not deleted when `Incidents.archive` returns False. - - This is the way of signaling that the relay failed, and we should not remove the original, - as that would result in losing the incident record. - """ - incident = MockMessage() - - with patch("bot.cogs.moderation.incidents.Incidents.archive", AsyncMock(return_value=False)): - await self.cog_instance.process_event( - reaction=incidents.Signal.ACTIONED.value, - incident=incident, - member=MockMember(roles=[MockRole(id=1)]) - ) - - incident.delete.assert_not_called() - - async def test_process_event_confirmation_task_is_awaited(self): - """Task given by `Incidents.make_confirmation_task` is awaited before method exits.""" - mock_task = AsyncMock() - - with patch("bot.cogs.moderation.incidents.Incidents.make_confirmation_task", mock_task): - await self.cog_instance.process_event( - reaction=incidents.Signal.ACTIONED.value, - incident=MockMessage(), - member=MockMember(roles=[MockRole(id=1)]) - ) - - mock_task.assert_awaited() - - async def test_process_event_confirmation_task_timeout_is_handled(self): - """ - Confirmation task `asyncio.TimeoutError` is handled gracefully. - - We have `make_confirmation_task` return a mock with a side effect, and then catch the - exception should it propagate out of `process_event`. This is so that we can then manually - fail the test with a more informative message than just the plain traceback. - """ - mock_task = AsyncMock(side_effect=asyncio.TimeoutError()) - - try: - with patch("bot.cogs.moderation.incidents.Incidents.make_confirmation_task", mock_task): - await self.cog_instance.process_event( - reaction=incidents.Signal.ACTIONED.value, - incident=MockMessage(), - member=MockMember(roles=[MockRole(id=1)]) - ) - except asyncio.TimeoutError: - self.fail("TimeoutError was not handled gracefully, and propagated out of `process_event`!") - - -class TestResolveMessage(TestIncidents): - """Tests for the `Incidents.resolve_message` coroutine.""" - - async def test_resolve_message_pass_message_id(self): - """Method will call `_get_message` with the passed `message_id`.""" - await self.cog_instance.resolve_message(123) - self.cog_instance.bot._connection._get_message.assert_called_once_with(123) - - async def test_resolve_message_in_cache(self): - """ - No API call is made if the queried message exists in the cache. - - We mock the `_get_message` return value regardless of input. Whether it finds the message - internally is considered d.py's responsibility, not ours. - """ - cached_message = MockMessage(id=123) - self.cog_instance.bot._connection._get_message = MagicMock(return_value=cached_message) - - return_value = await self.cog_instance.resolve_message(123) - - self.assertIs(return_value, cached_message) - self.cog_instance.bot.get_channel.assert_not_called() # The `fetch_message` line was never hit - - async def test_resolve_message_not_in_cache(self): - """ - The message is retrieved from the API if it isn't cached. - - This is desired behaviour for messages which exist, but were sent before the bot's - current session. - """ - self.cog_instance.bot._connection._get_message = MagicMock(return_value=None) # Cache returns None - - # API returns our message - uncached_message = MockMessage() - fetch_message = AsyncMock(return_value=uncached_message) - self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(fetch_message=fetch_message)) - - retrieved_message = await self.cog_instance.resolve_message(123) - self.assertIs(retrieved_message, uncached_message) - - async def test_resolve_message_doesnt_exist(self): - """ - If the API returns a 404, the function handles it gracefully and returns None. - - This is an edge-case happening with racing events - event A will relay the message - to the archive and delete the original. Once event B acquires the `event_lock`, - it will not find the message in the cache, and will ask the API. - """ - self.cog_instance.bot._connection._get_message = MagicMock(return_value=None) # Cache returns None - - fetch_message = AsyncMock(side_effect=mock_404) - self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(fetch_message=fetch_message)) - - self.assertIsNone(await self.cog_instance.resolve_message(123)) - - async def test_resolve_message_fetch_fails(self): - """ - Non-404 errors are handled, logged & None is returned. - - In contrast with a 404, this should make an error-level log. We assert that at least - one such log was made - we do not make any assertions about the log's message. - """ - self.cog_instance.bot._connection._get_message = MagicMock(return_value=None) # Cache returns None - - arbitrary_error = discord.HTTPException( - response=MagicMock(aiohttp.ClientResponse), - message="Arbitrary error", - ) - fetch_message = AsyncMock(side_effect=arbitrary_error) - self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(fetch_message=fetch_message)) - - with self.assertLogs(logger=incidents.log, level=logging.ERROR): - self.assertIsNone(await self.cog_instance.resolve_message(123)) - - -@patch("bot.constants.Channels.incidents", 123) -class TestOnRawReactionAdd(TestIncidents): - """ - Tests for the `Incidents.on_raw_reaction_add` listener. - - Writing tests for this listener comes with additional complexity due to the listener - awaiting the `crawl_task` task. See `asyncSetUp` for further details, which attempts - to make unit testing this function possible. - """ - - def setUp(self): - """ - Prepare & assign `payload` attribute. - - This attribute represents an *ideal* payload which will not be rejected by the - listener. As each test will receive a fresh instance, it can be mutated to - observe how the listener's behaviour changes with different attributes on - the passed payload. - """ - super().setUp() # Ensure `cog_instance` is assigned - - self.payload = MagicMock( - discord.RawReactionActionEvent, - channel_id=123, # Patched at class level - message_id=456, - member=MockMember(bot=False), - emoji="reaction", - ) - - async def asyncSetUp(self): # noqa: N802 - """ - Prepare an empty task and assign it as `crawl_task`. - - It appears that the `unittest` framework does not provide anything for mocking - asyncio tasks. An `AsyncMock` instance can be called and then awaited, however, - it does not provide the `done` method or any other parts of the `asyncio.Task` - interface. - - Although we do not need to make any assertions about the task itself while - testing the listener, the code will still await it and call the `done` method, - and so we must inject something that will not fail on either action. - - Note that this is done in an `asyncSetUp`, which runs after `setUp`. - The justification is that creating an actual task requires the event - loop to be ready, which is not the case in the `setUp`. - """ - mock_task = asyncio.create_task(AsyncMock()()) # Mock async func, then a coro - self.cog_instance.crawl_task = mock_task - - async def test_on_raw_reaction_add_wrong_channel(self): - """ - Events outside of #incidents will be ignored. - - We check this by asserting that `resolve_message` was never queried. - """ - self.payload.channel_id = 0 - self.cog_instance.resolve_message = AsyncMock() - - await self.cog_instance.on_raw_reaction_add(self.payload) - self.cog_instance.resolve_message.assert_not_called() - - async def test_on_raw_reaction_add_user_is_bot(self): - """ - Events dispatched by bot accounts will be ignored. - - We check this by asserting that `resolve_message` was never queried. - """ - self.payload.member = MockMember(bot=True) - self.cog_instance.resolve_message = AsyncMock() - - await self.cog_instance.on_raw_reaction_add(self.payload) - self.cog_instance.resolve_message.assert_not_called() - - async def test_on_raw_reaction_add_message_doesnt_exist(self): - """ - Listener gracefully handles the case where `resolve_message` gives None. - - We check this by asserting that `process_event` was never called. - """ - self.cog_instance.process_event = AsyncMock() - self.cog_instance.resolve_message = AsyncMock(return_value=None) - - await self.cog_instance.on_raw_reaction_add(self.payload) - self.cog_instance.process_event.assert_not_called() - - async def test_on_raw_reaction_add_message_is_not_an_incident(self): - """ - The event won't be processed if the related message is not an incident. - - This is an edge-case that can happen if someone manually leaves a reaction - on a pinned message, or a comment. - - We check this by asserting that `process_event` was never called. - """ - self.cog_instance.process_event = AsyncMock() - self.cog_instance.resolve_message = AsyncMock(return_value=MockMessage()) - - with patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=False)): - await self.cog_instance.on_raw_reaction_add(self.payload) - - self.cog_instance.process_event.assert_not_called() - - async def test_on_raw_reaction_add_valid_event_is_processed(self): - """ - If the reaction event is valid, it is passed to `process_event`. - - This is the case when everything goes right: - * The reaction was placed in #incidents, and not by a bot - * The message was found successfully - * The message qualifies as an incident - - Additionally, we check that all arguments were passed as expected. - """ - incident = MockMessage(id=1) - - self.cog_instance.process_event = AsyncMock() - self.cog_instance.resolve_message = AsyncMock(return_value=incident) - - with patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=True)): - await self.cog_instance.on_raw_reaction_add(self.payload) - - self.cog_instance.process_event.assert_called_with( - "reaction", # Defined in `self.payload` - incident, - self.payload.member, - ) - - -class TestOnMessage(TestIncidents): - """ - Tests for the `Incidents.on_message` listener. - - Notice the decorators mocking the `is_incident` return value. The `is_incidents` - function is tested in `TestIsIncident` - here we do not worry about it. - """ - - @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=True)) - async def test_on_message_incident(self): - """Messages qualifying as incidents are passed to `add_signals`.""" - incident = MockMessage() - - with patch("bot.cogs.moderation.incidents.add_signals", AsyncMock()) as mock_add_signals: - await self.cog_instance.on_message(incident) - - mock_add_signals.assert_called_once_with(incident) - - @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=False)) - async def test_on_message_non_incident(self): - """Messages not qualifying as incidents are ignored.""" - with patch("bot.cogs.moderation.incidents.add_signals", AsyncMock()) as mock_add_signals: - await self.cog_instance.on_message(MockMessage()) - - mock_add_signals.assert_not_called() diff --git a/tests/bot/cogs/moderation/test_modlog.py b/tests/bot/cogs/moderation/test_modlog.py deleted file mode 100644 index f2809f40a..000000000 --- a/tests/bot/cogs/moderation/test_modlog.py +++ /dev/null @@ -1,29 +0,0 @@ -import unittest - -import discord - -from bot.cogs.moderation.modlog import ModLog -from tests.helpers import MockBot, MockTextChannel - - -class ModLogTests(unittest.IsolatedAsyncioTestCase): - """Tests for moderation logs.""" - - def setUp(self): - self.bot = MockBot() - self.cog = ModLog(self.bot) - self.channel = MockTextChannel() - - async def test_log_entry_description_truncation(self): - """Test that embed description for ModLog entry is truncated.""" - self.bot.get_channel.return_value = self.channel - await self.cog.send_log_message( - icon_url="foo", - colour=discord.Colour.blue(), - title="bar", - text="foo bar" * 3000 - ) - embed = self.channel.send.call_args[1]["embed"] - self.assertEqual( - embed.description, ("foo bar" * 3000)[:2045] + "..." - ) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py deleted file mode 100644 index ab3d0742a..000000000 --- a/tests/bot/cogs/moderation/test_silence.py +++ /dev/null @@ -1,261 +0,0 @@ -import unittest -from unittest import mock -from unittest.mock import MagicMock, Mock - -from discord import PermissionOverwrite - -from bot.cogs.moderation.silence import Silence, SilenceNotifier -from bot.constants import Channels, Emojis, Guild, Roles -from tests.helpers import MockBot, MockContext, MockTextChannel - - -class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): - def setUp(self) -> None: - self.alert_channel = MockTextChannel() - self.notifier = SilenceNotifier(self.alert_channel) - self.notifier.stop = self.notifier_stop_mock = Mock() - self.notifier.start = self.notifier_start_mock = Mock() - - def test_add_channel_adds_channel(self): - """Channel in FirstHash with current loop is added to internal set.""" - channel = Mock() - with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels: - self.notifier.add_channel(channel) - silenced_channels.__setitem__.assert_called_with(channel, self.notifier._current_loop) - - def test_add_channel_starts_loop(self): - """Loop is started if `_silenced_channels` was empty.""" - self.notifier.add_channel(Mock()) - self.notifier_start_mock.assert_called_once() - - def test_add_channel_skips_start_with_channels(self): - """Loop start is not called when `_silenced_channels` is not empty.""" - with mock.patch.object(self.notifier, "_silenced_channels"): - self.notifier.add_channel(Mock()) - self.notifier_start_mock.assert_not_called() - - def test_remove_channel_removes_channel(self): - """Channel in FirstHash is removed from `_silenced_channels`.""" - channel = Mock() - with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels: - self.notifier.remove_channel(channel) - silenced_channels.__delitem__.assert_called_with(channel) - - def test_remove_channel_stops_loop(self): - """Notifier loop is stopped if `_silenced_channels` is empty after remove.""" - with mock.patch.object(self.notifier, "_silenced_channels", __bool__=lambda _: False): - self.notifier.remove_channel(Mock()) - self.notifier_stop_mock.assert_called_once() - - def test_remove_channel_skips_stop_with_channels(self): - """Notifier loop is not stopped if `_silenced_channels` is not empty after remove.""" - self.notifier.remove_channel(Mock()) - self.notifier_stop_mock.assert_not_called() - - async def test_notifier_private_sends_alert(self): - """Alert is sent on 15 min intervals.""" - test_cases = (900, 1800, 2700) - for current_loop in test_cases: - with self.subTest(current_loop=current_loop): - with mock.patch.object(self.notifier, "_current_loop", new=current_loop): - await self.notifier._notifier() - self.alert_channel.send.assert_called_once_with(f"<@&{Roles.moderators}> currently silenced channels: ") - self.alert_channel.send.reset_mock() - - async def test_notifier_skips_alert(self): - """Alert is skipped on first loop or not an increment of 900.""" - test_cases = (0, 15, 5000) - for current_loop in test_cases: - with self.subTest(current_loop=current_loop): - with mock.patch.object(self.notifier, "_current_loop", new=current_loop): - await self.notifier._notifier() - self.alert_channel.send.assert_not_called() - - -class SilenceTests(unittest.IsolatedAsyncioTestCase): - def setUp(self) -> None: - self.bot = MockBot() - self.cog = Silence(self.bot) - self.ctx = MockContext() - self.cog._verified_role = None - # Set event so command callbacks can continue. - self.cog._get_instance_vars_event.set() - - async def test_instance_vars_got_guild(self): - """Bot got guild after it became available.""" - await self.cog._get_instance_vars() - self.bot.wait_until_guild_available.assert_called_once() - self.bot.get_guild.assert_called_once_with(Guild.id) - - async def test_instance_vars_got_role(self): - """Got `Roles.verified` role from guild.""" - await self.cog._get_instance_vars() - guild = self.bot.get_guild() - guild.get_role.assert_called_once_with(Roles.verified) - - async def test_instance_vars_got_channels(self): - """Got channels from bot.""" - await self.cog._get_instance_vars() - self.bot.get_channel.called_once_with(Channels.mod_alerts) - self.bot.get_channel.called_once_with(Channels.mod_log) - - @mock.patch("bot.cogs.moderation.silence.SilenceNotifier") - async def test_instance_vars_got_notifier(self, notifier): - """Notifier was started with channel.""" - mod_log = MockTextChannel() - self.bot.get_channel.side_effect = (None, mod_log) - await self.cog._get_instance_vars() - notifier.assert_called_once_with(mod_log) - self.bot.get_channel.side_effect = None - - async def test_silence_sent_correct_discord_message(self): - """Check if proper message was sent when called with duration in channel with previous state.""" - test_cases = ( - (0.0001, f"{Emojis.check_mark} silenced current channel for 0.0001 minute(s).", True,), - (None, f"{Emojis.check_mark} silenced current channel indefinitely.", True,), - (5, f"{Emojis.cross_mark} current channel is already silenced.", False,), - ) - for duration, result_message, _silence_patch_return in test_cases: - with self.subTest( - silence_duration=duration, - result_message=result_message, - starting_unsilenced_state=_silence_patch_return - ): - with mock.patch.object(self.cog, "_silence", return_value=_silence_patch_return): - await self.cog.silence.callback(self.cog, self.ctx, duration) - self.ctx.send.assert_called_once_with(result_message) - self.ctx.reset_mock() - - async def test_unsilence_sent_correct_discord_message(self): - """Check if proper message was sent when unsilencing channel.""" - test_cases = ( - (True, f"{Emojis.check_mark} unsilenced current channel."), - (False, f"{Emojis.cross_mark} current channel was not silenced.") - ) - for _unsilence_patch_return, result_message in test_cases: - with self.subTest( - starting_silenced_state=_unsilence_patch_return, - result_message=result_message - ): - with mock.patch.object(self.cog, "_unsilence", return_value=_unsilence_patch_return): - await self.cog.unsilence.callback(self.cog, self.ctx) - self.ctx.send.assert_called_once_with(result_message) - self.ctx.reset_mock() - - async def test_silence_private_for_false(self): - """Permissions are not set and `False` is returned in an already silenced channel.""" - perm_overwrite = Mock(send_messages=False) - channel = Mock(overwrites_for=Mock(return_value=perm_overwrite)) - - self.assertFalse(await self.cog._silence(channel, True, None)) - channel.set_permissions.assert_not_called() - - async def test_silence_private_silenced_channel(self): - """Channel had `send_message` permissions revoked.""" - channel = MockTextChannel() - self.assertTrue(await self.cog._silence(channel, False, None)) - channel.set_permissions.assert_called_once() - self.assertFalse(channel.set_permissions.call_args.kwargs['send_messages']) - - async def test_silence_private_preserves_permissions(self): - """Previous permissions were preserved when channel was silenced.""" - channel = MockTextChannel() - # Set up mock channel permission state. - mock_permissions = PermissionOverwrite() - mock_permissions_dict = dict(mock_permissions) - channel.overwrites_for.return_value = mock_permissions - await self.cog._silence(channel, False, None) - new_permissions = channel.set_permissions.call_args.kwargs - # Remove 'send_messages' key because it got changed in the method. - del new_permissions['send_messages'] - del mock_permissions_dict['send_messages'] - self.assertDictEqual(mock_permissions_dict, new_permissions) - - async def test_silence_private_notifier(self): - """Channel should be added to notifier with `persistent` set to `True`, and the other way around.""" - channel = MockTextChannel() - with mock.patch.object(self.cog, "notifier", create=True): - with self.subTest(persistent=True): - await self.cog._silence(channel, True, None) - self.cog.notifier.add_channel.assert_called_once() - - with mock.patch.object(self.cog, "notifier", create=True): - with self.subTest(persistent=False): - await self.cog._silence(channel, False, None) - self.cog.notifier.add_channel.assert_not_called() - - async def test_silence_private_added_muted_channel(self): - """Channel was added to `muted_channels` on silence.""" - channel = MockTextChannel() - with mock.patch.object(self.cog, "muted_channels") as muted_channels: - await self.cog._silence(channel, False, None) - muted_channels.add.assert_called_once_with(channel) - - async def test_unsilence_private_for_false(self): - """Permissions are not set and `False` is returned in an unsilenced channel.""" - channel = Mock() - self.assertFalse(await self.cog._unsilence(channel)) - channel.set_permissions.assert_not_called() - - @mock.patch.object(Silence, "notifier", create=True) - async def test_unsilence_private_unsilenced_channel(self, _): - """Channel had `send_message` permissions restored""" - perm_overwrite = MagicMock(send_messages=False) - channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) - self.assertTrue(await self.cog._unsilence(channel)) - channel.set_permissions.assert_called_once() - self.assertIsNone(channel.set_permissions.call_args.kwargs['send_messages']) - - @mock.patch.object(Silence, "notifier", create=True) - async def test_unsilence_private_removed_notifier(self, notifier): - """Channel was removed from `notifier` on unsilence.""" - perm_overwrite = MagicMock(send_messages=False) - channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) - await self.cog._unsilence(channel) - notifier.remove_channel.assert_called_once_with(channel) - - @mock.patch.object(Silence, "notifier", create=True) - async def test_unsilence_private_removed_muted_channel(self, _): - """Channel was removed from `muted_channels` on unsilence.""" - perm_overwrite = MagicMock(send_messages=False) - channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) - with mock.patch.object(self.cog, "muted_channels") as muted_channels: - await self.cog._unsilence(channel) - muted_channels.discard.assert_called_once_with(channel) - - @mock.patch.object(Silence, "notifier", create=True) - async def test_unsilence_private_preserves_permissions(self, _): - """Previous permissions were preserved when channel was unsilenced.""" - channel = MockTextChannel() - # Set up mock channel permission state. - mock_permissions = PermissionOverwrite(send_messages=False) - mock_permissions_dict = dict(mock_permissions) - channel.overwrites_for.return_value = mock_permissions - await self.cog._unsilence(channel) - new_permissions = channel.set_permissions.call_args.kwargs - # Remove 'send_messages' key because it got changed in the method. - del new_permissions['send_messages'] - del mock_permissions_dict['send_messages'] - self.assertDictEqual(mock_permissions_dict, new_permissions) - - @mock.patch("bot.cogs.moderation.silence.asyncio") - @mock.patch.object(Silence, "_mod_alerts_channel", create=True) - def test_cog_unload_starts_task(self, alert_channel, asyncio_mock): - """Task for sending an alert was created with present `muted_channels`.""" - with mock.patch.object(self.cog, "muted_channels"): - self.cog.cog_unload() - alert_channel.send.assert_called_once_with(f"<@&{Roles.moderators}> channels left silenced on cog unload: ") - asyncio_mock.create_task.assert_called_once_with(alert_channel.send()) - - @mock.patch("bot.cogs.moderation.silence.asyncio") - def test_cog_unload_skips_task_start(self, asyncio_mock): - """No task created with no channels.""" - self.cog.cog_unload() - asyncio_mock.create_task.assert_not_called() - - @mock.patch("bot.cogs.moderation.silence.with_role_check") - @mock.patch("bot.cogs.moderation.silence.MODERATION_ROLES", new=(1, 2, 3)) - def test_cog_check(self, role_check): - """Role check is called with `MODERATION_ROLES`""" - self.cog.cog_check(self.ctx) - role_check.assert_called_once_with(self.ctx, *(1, 2, 3)) diff --git a/tests/bot/cogs/moderation/test_slowmode.py b/tests/bot/cogs/moderation/test_slowmode.py deleted file mode 100644 index f442814c8..000000000 --- a/tests/bot/cogs/moderation/test_slowmode.py +++ /dev/null @@ -1,111 +0,0 @@ -import unittest -from unittest import mock - -from dateutil.relativedelta import relativedelta - -from bot.cogs.moderation.slowmode import Slowmode -from bot.constants import Emojis -from tests.helpers import MockBot, MockContext, MockTextChannel - - -class SlowmodeTests(unittest.IsolatedAsyncioTestCase): - - def setUp(self) -> None: - self.bot = MockBot() - self.cog = Slowmode(self.bot) - self.ctx = MockContext() - - async def test_get_slowmode_no_channel(self) -> None: - """Get slowmode without a given channel.""" - self.ctx.channel = MockTextChannel(name='python-general', slowmode_delay=5) - - await self.cog.get_slowmode(self.cog, self.ctx, None) - self.ctx.send.assert_called_once_with("The slowmode delay for #python-general is 5 seconds.") - - async def test_get_slowmode_with_channel(self) -> None: - """Get slowmode with a given channel.""" - text_channel = MockTextChannel(name='python-language', slowmode_delay=2) - - await self.cog.get_slowmode(self.cog, self.ctx, text_channel) - self.ctx.send.assert_called_once_with('The slowmode delay for #python-language is 2 seconds.') - - async def test_set_slowmode_no_channel(self) -> None: - """Set slowmode without a given channel.""" - test_cases = ( - ('helpers', 23, True, f'{Emojis.check_mark} The slowmode delay for #helpers is now 23 seconds.'), - ('mods', 76526, False, f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.'), - ('admins', 97, True, f'{Emojis.check_mark} The slowmode delay for #admins is now 1 minute and 37 seconds.') - ) - - for channel_name, seconds, edited, result_msg in test_cases: - with self.subTest( - channel_mention=channel_name, - seconds=seconds, - edited=edited, - result_msg=result_msg - ): - self.ctx.channel = MockTextChannel(name=channel_name) - - await self.cog.set_slowmode(self.cog, self.ctx, None, relativedelta(seconds=seconds)) - - if edited: - self.ctx.channel.edit.assert_awaited_once_with(slowmode_delay=float(seconds)) - else: - self.ctx.channel.edit.assert_not_called() - - self.ctx.send.assert_called_once_with(result_msg) - - self.ctx.reset_mock() - - async def test_set_slowmode_with_channel(self) -> None: - """Set slowmode with a given channel.""" - test_cases = ( - ('bot-commands', 12, True, f'{Emojis.check_mark} The slowmode delay for #bot-commands is now 12 seconds.'), - ('mod-spam', 21, True, f'{Emojis.check_mark} The slowmode delay for #mod-spam is now 21 seconds.'), - ('admin-spam', 4323598, False, f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.') - ) - - for channel_name, seconds, edited, result_msg in test_cases: - with self.subTest( - channel_mention=channel_name, - seconds=seconds, - edited=edited, - result_msg=result_msg - ): - text_channel = MockTextChannel(name=channel_name) - - await self.cog.set_slowmode(self.cog, self.ctx, text_channel, relativedelta(seconds=seconds)) - - if edited: - text_channel.edit.assert_awaited_once_with(slowmode_delay=float(seconds)) - else: - text_channel.edit.assert_not_called() - - self.ctx.send.assert_called_once_with(result_msg) - - self.ctx.reset_mock() - - async def test_reset_slowmode_no_channel(self) -> None: - """Reset slowmode without a given channel.""" - self.ctx.channel = MockTextChannel(name='careers', slowmode_delay=6) - - await self.cog.reset_slowmode(self.cog, self.ctx, None) - self.ctx.send.assert_called_once_with( - f'{Emojis.check_mark} The slowmode delay for #careers has been reset to 0 seconds.' - ) - - async def test_reset_slowmode_with_channel(self) -> None: - """Reset slowmode with a given channel.""" - text_channel = MockTextChannel(name='meta', slowmode_delay=1) - - await self.cog.reset_slowmode(self.cog, self.ctx, text_channel) - self.ctx.send.assert_called_once_with( - f'{Emojis.check_mark} The slowmode delay for #meta has been reset to 0 seconds.' - ) - - @mock.patch("bot.cogs.moderation.slowmode.with_role_check") - @mock.patch("bot.cogs.moderation.slowmode.MODERATION_ROLES", new=(1, 2, 3)) - def test_cog_check(self, role_check): - """Role check is called with `MODERATION_ROLES`""" - self.cog.cog_check(self.ctx) - role_check.assert_called_once_with(self.ctx, *(1, 2, 3)) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py deleted file mode 100644 index fdda59a8f..000000000 --- a/tests/bot/cogs/test_cogs.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Test suite for general tests which apply to all cogs.""" - -import importlib -import pkgutil -import typing as t -import unittest -from collections import defaultdict -from types import ModuleType -from unittest import mock - -from discord.ext import commands - -from bot import cogs - - -class CommandNameTests(unittest.TestCase): - """Tests for shadowing command names and aliases.""" - - @staticmethod - def walk_commands(cog: commands.Cog) -> t.Iterator[commands.Command]: - """An iterator that recursively walks through `cog`'s commands and subcommands.""" - # Can't use Bot.walk_commands() or Cog.get_commands() cause those are instance methods. - for command in cog.__cog_commands__: - if command.parent is None: - yield command - if isinstance(command, commands.GroupMixin): - # Annoyingly it returns duplicates for each alias so use a set to fix that - yield from set(command.walk_commands()) - - @staticmethod - def walk_modules() -> t.Iterator[ModuleType]: - """Yield imported modules from the bot.cogs subpackage.""" - def on_error(name: str) -> t.NoReturn: - raise ImportError(name=name) # pragma: no cover - - # The mock prevents asyncio.get_event_loop() from being called. - with mock.patch("discord.ext.tasks.loop"): - for module in pkgutil.walk_packages(cogs.__path__, "bot.cogs.", onerror=on_error): - if not module.ispkg: - yield importlib.import_module(module.name) - - @staticmethod - def walk_cogs(module: ModuleType) -> t.Iterator[commands.Cog]: - """Yield all cogs defined in an extension.""" - for obj in module.__dict__.values(): - # Check if it's a class type cause otherwise issubclass() may raise a TypeError. - is_cog = isinstance(obj, type) and issubclass(obj, commands.Cog) - if is_cog and obj.__module__ == module.__name__: - yield obj - - @staticmethod - def get_qualified_names(command: commands.Command) -> t.List[str]: - """Return a list of all qualified names, including aliases, for the `command`.""" - names = [f"{command.full_parent_name} {alias}".strip() for alias in command.aliases] - names.append(command.qualified_name) - - return names - - def get_all_commands(self) -> t.Iterator[commands.Command]: - """Yield all commands for all cogs in all extensions.""" - for module in self.walk_modules(): - for cog in self.walk_cogs(module): - for cmd in self.walk_commands(cog): - yield cmd - - def test_names_dont_shadow(self): - """Names and aliases of commands should be unique.""" - all_names = defaultdict(list) - for cmd in self.get_all_commands(): - func_name = f"{cmd.module}.{cmd.callback.__qualname__}" - - for name in self.get_qualified_names(cmd): - with self.subTest(cmd=func_name, name=name): - if name in all_names: # pragma: no cover - conflicts = ", ".join(all_names.get(name, "")) - self.fail( - f"Name '{name}' of the command {func_name} conflicts with {conflicts}." - ) - - all_names[name].append(func_name) diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py deleted file mode 100644 index cfe10aebf..000000000 --- a/tests/bot/cogs/test_duck_pond.py +++ /dev/null @@ -1,548 +0,0 @@ -import asyncio -import logging -import typing -import unittest -from unittest.mock import AsyncMock, MagicMock, patch - -import discord - -from bot import constants -from bot.cogs import duck_pond -from tests import base -from tests import helpers - -MODULE_PATH = "bot.cogs.duck_pond" - - -class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): - """Tests for DuckPond functionality.""" - - @classmethod - def setUpClass(cls): - """Sets up the objects that only have to be initialized once.""" - cls.nonstaff_member = helpers.MockMember(name="Non-staffer") - - cls.staff_role = helpers.MockRole(name="Staff role", id=constants.STAFF_ROLES[0]) - cls.staff_member = helpers.MockMember(name="staffer", roles=[cls.staff_role]) - - cls.checkmark_emoji = "\N{White Heavy Check Mark}" - cls.thumbs_up_emoji = "\N{Thumbs Up Sign}" - cls.unicode_duck_emoji = "\N{Duck}" - cls.duck_pond_emoji = helpers.MockPartialEmoji(id=constants.DuckPond.custom_emojis[0]) - cls.non_duck_custom_emoji = helpers.MockPartialEmoji(id=123) - - def setUp(self): - """Sets up the objects that need to be refreshed before each test.""" - self.bot = helpers.MockBot(user=helpers.MockMember(id=46692)) - self.cog = duck_pond.DuckPond(bot=self.bot) - - def test_duck_pond_correctly_initializes(self): - """`__init__ should set `bot` and `webhook_id` attributes and schedule `fetch_webhook`.""" - bot = helpers.MockBot() - cog = MagicMock() - - duck_pond.DuckPond.__init__(cog, bot) - - self.assertEqual(cog.bot, bot) - self.assertEqual(cog.webhook_id, constants.Webhooks.duck_pond) - bot.loop.create_task.assert_called_once_with(cog.fetch_webhook()) - - def test_fetch_webhook_succeeds_without_connectivity_issues(self): - """The `fetch_webhook` method waits until `READY` event and sets the `webhook` attribute.""" - self.bot.fetch_webhook.return_value = "dummy webhook" - self.cog.webhook_id = 1 - - asyncio.run(self.cog.fetch_webhook()) - - self.bot.wait_until_guild_available.assert_called_once() - self.bot.fetch_webhook.assert_called_once_with(1) - self.assertEqual(self.cog.webhook, "dummy webhook") - - def test_fetch_webhook_logs_when_unable_to_fetch_webhook(self): - """The `fetch_webhook` method should log an exception when it fails to fetch the webhook.""" - self.bot.fetch_webhook.side_effect = discord.HTTPException(response=MagicMock(), message="Not found.") - self.cog.webhook_id = 1 - - log = logging.getLogger('bot.cogs.duck_pond') - with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: - asyncio.run(self.cog.fetch_webhook()) - - self.bot.wait_until_guild_available.assert_called_once() - self.bot.fetch_webhook.assert_called_once_with(1) - - self.assertEqual(len(log_watcher.records), 1) - - record = log_watcher.records[0] - self.assertEqual(record.levelno, logging.ERROR) - - def test_is_staff_returns_correct_values_based_on_instance_passed(self): - """The `is_staff` method should return correct values based on the instance passed.""" - test_cases = ( - (helpers.MockUser(name="User instance"), False), - (helpers.MockMember(name="Member instance without staff role"), False), - (helpers.MockMember(name="Member instance with staff role", roles=[self.staff_role]), True) - ) - - for user, expected_return in test_cases: - actual_return = self.cog.is_staff(user) - with self.subTest(user_type=user.name, expected_return=expected_return, actual_return=actual_return): - self.assertEqual(expected_return, actual_return) - - async def test_has_green_checkmark_correctly_detects_presence_of_green_checkmark_emoji(self): - """The `has_green_checkmark` method should only return `True` if one is present.""" - test_cases = ( - ( - "No reactions", helpers.MockMessage(), False - ), - ( - "No green check mark reactions", - helpers.MockMessage(reactions=[ - helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]), - helpers.MockReaction(emoji=self.thumbs_up_emoji, users=[self.bot.user]) - ]), - False - ), - ( - "Green check mark reaction, but not from our bot", - helpers.MockMessage(reactions=[ - helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]), - helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.staff_member]) - ]), - False - ), - ( - "Green check mark reaction, with one from the bot", - helpers.MockMessage(reactions=[ - helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]), - helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.staff_member, self.bot.user]) - ]), - True - ) - ) - - for description, message, expected_return in test_cases: - actual_return = await self.cog.has_green_checkmark(message) - with self.subTest( - test_case=description, - expected_return=expected_return, - actual_return=actual_return - ): - self.assertEqual(expected_return, actual_return) - - def _get_reaction( - self, - emoji: typing.Union[str, helpers.MockEmoji], - staff: int = 0, - nonstaff: int = 0 - ) -> helpers.MockReaction: - staffers = [helpers.MockMember(roles=[self.staff_role]) for _ in range(staff)] - nonstaffers = [helpers.MockMember() for _ in range(nonstaff)] - return helpers.MockReaction(emoji=emoji, users=staffers + nonstaffers) - - async def test_count_ducks_correctly_counts_the_number_of_eligible_duck_emojis(self): - """The `count_ducks` method should return the number of unique staffers who gave a duck.""" - test_cases = ( - # Simple test cases - # A message without reactions should return 0 - ( - "No reactions", - helpers.MockMessage(), - 0 - ), - # A message with a non-duck reaction from a non-staffer should return 0 - ( - "Non-duck reaction from non-staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.thumbs_up_emoji, nonstaff=1)]), - 0 - ), - # A message with a non-duck reaction from a staffer should return 0 - ( - "Non-duck reaction from staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.non_duck_custom_emoji, staff=1)]), - 0 - ), - # A message with a non-duck reaction from a non-staffer and staffer should return 0 - ( - "Non-duck reaction from staffer + non-staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.thumbs_up_emoji, staff=1, nonstaff=1)]), - 0 - ), - # A message with a unicode duck reaction from a non-staffer should return 0 - ( - "Unicode Duck Reaction from non-staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, nonstaff=1)]), - 0 - ), - # A message with a unicode duck reaction from a staffer should return 1 - ( - "Unicode Duck Reaction from staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, staff=1)]), - 1 - ), - # A message with a unicode duck reaction from a non-staffer and staffer should return 1 - ( - "Unicode Duck Reaction from staffer + non-staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, staff=1, nonstaff=1)]), - 1 - ), - # A message with a duckpond duck reaction from a non-staffer should return 0 - ( - "Duckpond Duck Reaction from non-staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, nonstaff=1)]), - 0 - ), - # A message with a duckpond duck reaction from a staffer should return 1 - ( - "Duckpond Duck Reaction from staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=1)]), - 1 - ), - # A message with a duckpond duck reaction from a non-staffer and staffer should return 1 - ( - "Duckpond Duck Reaction from staffer + non-staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=1, nonstaff=1)]), - 1 - ), - - # Complex test cases - # A message with duckpond duck reactions from 3 staffers and 2 non-staffers returns 3 - ( - "Duckpond Duck Reaction from 3 staffers + 2 non-staffers", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=3, nonstaff=2)]), - 3 - ), - # A staffer with multiple duck reactions only counts once - ( - "Two different duck reactions from the same staffer", - helpers.MockMessage( - reactions=[ - helpers.MockReaction(emoji=self.duck_pond_emoji, users=[self.staff_member]), - helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.staff_member]), - ] - ), - 1 - ), - # A non-string emoji does not count (to test the `isinstance(reaction.emoji, str)` elif) - ( - "Reaction with non-Emoji/str emoij from 3 staffers + 2 non-staffers", - helpers.MockMessage(reactions=[self._get_reaction(emoji=100, staff=3, nonstaff=2)]), - 0 - ), - # We correctly sum when multiple reactions are provided. - ( - "Duckpond Duck Reaction from 3 staffers + 2 non-staffers", - helpers.MockMessage( - reactions=[ - self._get_reaction(emoji=self.duck_pond_emoji, staff=3, nonstaff=2), - self._get_reaction(emoji=self.unicode_duck_emoji, staff=4, nonstaff=9), - ] - ), - 3 + 4 - ), - ) - - for description, message, expected_count in test_cases: - actual_count = await self.cog.count_ducks(message) - with self.subTest(test_case=description, expected_count=expected_count, actual_count=actual_count): - self.assertEqual(expected_count, actual_count) - - async def test_relay_message_correctly_relays_content_and_attachments(self): - """The `relay_message` method should correctly relay message content and attachments.""" - send_webhook_path = f"{MODULE_PATH}.send_webhook" - send_attachments_path = f"{MODULE_PATH}.send_attachments" - author = MagicMock( - display_name="x", - avatar_url="https://" - ) - - self.cog.webhook = helpers.MockAsyncWebhook() - - test_values = ( - (helpers.MockMessage(author=author, clean_content="", attachments=[]), False, False), - (helpers.MockMessage(author=author, clean_content="message", attachments=[]), True, False), - (helpers.MockMessage(author=author, clean_content="", attachments=["attachment"]), False, True), - (helpers.MockMessage(author=author, clean_content="message", attachments=["attachment"]), True, True), - ) - - for message, expect_webhook_call, expect_attachment_call in test_values: - with patch(send_webhook_path, new_callable=AsyncMock) as send_webhook: - with patch(send_attachments_path, new_callable=AsyncMock) as send_attachments: - with self.subTest(clean_content=message.clean_content, attachments=message.attachments): - await self.cog.relay_message(message) - - self.assertEqual(expect_webhook_call, send_webhook.called) - self.assertEqual(expect_attachment_call, send_attachments.called) - - message.add_reaction.assert_called_once_with(self.checkmark_emoji) - - @patch(f"{MODULE_PATH}.send_attachments", new_callable=AsyncMock) - async def test_relay_message_handles_irretrievable_attachment_exceptions(self, send_attachments): - """The `relay_message` method should handle irretrievable attachments.""" - message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) - side_effects = (discord.errors.Forbidden(MagicMock(), ""), discord.errors.NotFound(MagicMock(), "")) - - self.cog.webhook = helpers.MockAsyncWebhook() - log = logging.getLogger("bot.cogs.duck_pond") - - for side_effect in side_effects: # pragma: no cover - send_attachments.side_effect = side_effect - with patch(f"{MODULE_PATH}.send_webhook", new_callable=AsyncMock) as send_webhook: - with self.subTest(side_effect=type(side_effect).__name__): - with self.assertNotLogs(logger=log, level=logging.ERROR): - await self.cog.relay_message(message) - - self.assertEqual(send_webhook.call_count, 2) - - @patch(f"{MODULE_PATH}.send_webhook", new_callable=AsyncMock) - @patch(f"{MODULE_PATH}.send_attachments", new_callable=AsyncMock) - async def test_relay_message_handles_attachment_http_error(self, send_attachments, send_webhook): - """The `relay_message` method should handle irretrievable attachments.""" - message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) - - self.cog.webhook = helpers.MockAsyncWebhook() - log = logging.getLogger("bot.cogs.duck_pond") - - side_effect = discord.HTTPException(MagicMock(), "") - send_attachments.side_effect = side_effect - with self.subTest(side_effect=type(side_effect).__name__): - with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: - await self.cog.relay_message(message) - - send_webhook.assert_called_once_with( - webhook=self.cog.webhook, - content=message.clean_content, - username=message.author.display_name, - avatar_url=message.author.avatar_url - ) - - self.assertEqual(len(log_watcher.records), 1) - - record = log_watcher.records[0] - self.assertEqual(record.levelno, logging.ERROR) - - def _mock_payload(self, label: str, is_custom_emoji: bool, id_: int, emoji_name: str): - """Creates a mock `on_raw_reaction_add` payload with the specified emoji data.""" - payload = MagicMock(name=label) - payload.emoji.is_custom_emoji.return_value = is_custom_emoji - payload.emoji.id = id_ - payload.emoji.name = emoji_name - return payload - - async def test_payload_has_duckpond_emoji_correctly_detects_relevant_emojis(self): - """The `on_raw_reaction_add` event handler should ignore irrelevant emojis.""" - test_values = ( - # Custom Emojis - ( - self._mock_payload( - label="Custom Duckpond Emoji", - is_custom_emoji=True, - id_=constants.DuckPond.custom_emojis[0], - emoji_name="" - ), - True - ), - ( - self._mock_payload( - label="Custom Non-Duckpond Emoji", - is_custom_emoji=True, - id_=123, - emoji_name="" - ), - False - ), - # Unicode Emojis - ( - self._mock_payload( - label="Unicode Duck Emoji", - is_custom_emoji=False, - id_=1, - emoji_name=self.unicode_duck_emoji - ), - True - ), - ( - self._mock_payload( - label="Unicode Non-Duck Emoji", - is_custom_emoji=False, - id_=1, - emoji_name=self.thumbs_up_emoji - ), - False - ), - ) - - for payload, expected_return in test_values: - actual_return = self.cog._payload_has_duckpond_emoji(payload) - with self.subTest(case=payload._mock_name, expected_return=expected_return, actual_return=actual_return): - self.assertEqual(expected_return, actual_return) - - @patch(f"{MODULE_PATH}.discord.utils.get") - @patch(f"{MODULE_PATH}.DuckPond._payload_has_duckpond_emoji", new=MagicMock(return_value=False)) - def test_on_raw_reaction_add_returns_early_with_payload_without_duck_emoji(self, utils_get): - """The `on_raw_reaction_add` method should return early if the payload does not contain a duck emoji.""" - self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_add(payload=MagicMock()))) - - # Ensure we've returned before making an unnecessary API call in the lines of code after the emoji check - utils_get.assert_not_called() - - def _raw_reaction_mocks(self, channel_id, message_id, user_id): - """Sets up mocks for tests of the `on_raw_reaction_add` event listener.""" - channel = helpers.MockTextChannel(id=channel_id) - self.bot.get_all_channels.return_value = (channel,) - - message = helpers.MockMessage(id=message_id) - - channel.fetch_message.return_value = message - - member = helpers.MockMember(id=user_id, roles=[self.staff_role]) - message.guild.members = (member,) - - payload = MagicMock(channel_id=channel_id, message_id=message_id, user_id=user_id) - - return channel, message, member, payload - - async def test_on_raw_reaction_add_returns_for_bot_and_non_staff_members(self): - """The `on_raw_reaction_add` event handler should return for bot users or non-staff members.""" - channel_id = 1234 - message_id = 2345 - user_id = 3456 - - channel, message, _, payload = self._raw_reaction_mocks(channel_id, message_id, user_id) - - test_cases = ( - ("non-staff member", helpers.MockMember(id=user_id)), - ("bot staff member", helpers.MockMember(id=user_id, roles=[self.staff_role], bot=True)), - ) - - payload.emoji = self.duck_pond_emoji - - for description, member in test_cases: - message.guild.members = (member, ) - with self.subTest(test_case=description), patch(f"{MODULE_PATH}.DuckPond.has_green_checkmark") as checkmark: - checkmark.side_effect = AssertionError( - "Expected method to return before calling `self.has_green_checkmark`." - ) - self.assertIsNone(await self.cog.on_raw_reaction_add(payload)) - - # Check that we did make it past the payload checks - channel.fetch_message.assert_called_once() - channel.fetch_message.reset_mock() - - @patch(f"{MODULE_PATH}.DuckPond.is_staff") - @patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) - def test_on_raw_reaction_add_returns_on_message_with_green_checkmark_placed_by_bot(self, count_ducks, is_staff): - """The `on_raw_reaction_add` event should return when the message has a green check mark placed by the bot.""" - channel_id = 31415926535 - message_id = 27182818284 - user_id = 16180339887 - - channel, message, member, payload = self._raw_reaction_mocks(channel_id, message_id, user_id) - - payload.emoji = helpers.MockPartialEmoji(name=self.unicode_duck_emoji) - payload.emoji.is_custom_emoji.return_value = False - - message.reactions = [helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.bot.user])] - - is_staff.return_value = True - count_ducks.side_effect = AssertionError("Expected method to return before calling `self.count_ducks`") - - self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_add(payload))) - - # Assert that we've made it past `self.is_staff` - is_staff.assert_called_once() - - async def test_on_raw_reaction_add_does_not_relay_below_duck_threshold(self): - """The `on_raw_reaction_add` listener should not relay messages or attachments below the duck threshold.""" - test_cases = ( - (constants.DuckPond.threshold - 1, False), - (constants.DuckPond.threshold, True), - (constants.DuckPond.threshold + 1, True), - ) - - channel, message, member, payload = self._raw_reaction_mocks(channel_id=3, message_id=4, user_id=5) - - payload.emoji = self.duck_pond_emoji - - for duck_count, should_relay in test_cases: - with patch(f"{MODULE_PATH}.DuckPond.relay_message", new_callable=AsyncMock) as relay_message: - with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) as count_ducks: - count_ducks.return_value = duck_count - with self.subTest(duck_count=duck_count, should_relay=should_relay): - await self.cog.on_raw_reaction_add(payload) - - # Confirm that we've made it past counting - count_ducks.assert_called_once() - - # Did we relay a message? - has_relayed = relay_message.called - self.assertEqual(has_relayed, should_relay) - - if should_relay: - relay_message.assert_called_once_with(message) - - async def test_on_raw_reaction_remove_prevents_removal_of_green_checkmark_depending_on_the_duck_count(self): - """The `on_raw_reaction_remove` listener prevents removal of the check mark on messages with enough ducks.""" - checkmark = helpers.MockPartialEmoji(name=self.checkmark_emoji) - - message = helpers.MockMessage(id=1234) - - channel = helpers.MockTextChannel(id=98765) - channel.fetch_message.return_value = message - - self.bot.get_all_channels.return_value = (channel, ) - - payload = MagicMock(channel_id=channel.id, message_id=message.id, emoji=checkmark) - - test_cases = ( - (constants.DuckPond.threshold - 1, False), - (constants.DuckPond.threshold, True), - (constants.DuckPond.threshold + 1, True), - ) - for duck_count, should_re_add_checkmark in test_cases: - with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) as count_ducks: - count_ducks.return_value = duck_count - with self.subTest(duck_count=duck_count, should_re_add_checkmark=should_re_add_checkmark): - await self.cog.on_raw_reaction_remove(payload) - - # Check if we fetched the message - channel.fetch_message.assert_called_once_with(message.id) - - # Check if we actually counted the number of ducks - count_ducks.assert_called_once_with(message) - - has_re_added_checkmark = message.add_reaction.called - self.assertEqual(should_re_add_checkmark, has_re_added_checkmark) - - if should_re_add_checkmark: - message.add_reaction.assert_called_once_with(self.checkmark_emoji) - message.add_reaction.reset_mock() - - # reset mocks - channel.fetch_message.reset_mock() - message.reset_mock() - - def test_on_raw_reaction_remove_ignores_removal_of_non_checkmark_reactions(self): - """The `on_raw_reaction_remove` listener should ignore the removal of non-check mark emojis.""" - channel = helpers.MockTextChannel(id=98765) - - channel.fetch_message.side_effect = AssertionError( - "Expected method to return before calling `channel.fetch_message`" - ) - - self.bot.get_all_channels.return_value = (channel, ) - - payload = MagicMock(emoji=helpers.MockPartialEmoji(name=self.thumbs_up_emoji), channel_id=channel.id) - - self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_remove(payload))) - - channel.fetch_message.assert_not_called() - - -class DuckPondSetupTests(unittest.TestCase): - """Tests setup of the `DuckPond` cog.""" - - def test_setup(self): - """Setup of the extension should call add_cog.""" - bot = helpers.MockBot() - duck_pond.setup(bot) - bot.add_cog.assert_called_once() diff --git a/tests/bot/cogs/utils/__init__.py b/tests/bot/cogs/utils/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/bot/cogs/utils/test_jams.py b/tests/bot/cogs/utils/test_jams.py deleted file mode 100644 index 299f436ba..000000000 --- a/tests/bot/cogs/utils/test_jams.py +++ /dev/null @@ -1,173 +0,0 @@ -import unittest -from unittest.mock import AsyncMock, MagicMock, create_autospec - -from discord import CategoryChannel - -from bot.cogs.utils import jams -from bot.constants import Roles -from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockTextChannel - - -def get_mock_category(channel_count: int, name: str) -> CategoryChannel: - """Return a mocked code jam category.""" - category = create_autospec(CategoryChannel, spec_set=True, instance=True) - category.name = name - category.channels = [MockTextChannel() for _ in range(channel_count)] - - return category - - -class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): - """Tests for `createteam` command.""" - - def setUp(self): - self.bot = MockBot() - self.admin_role = MockRole(name="Admins", id=Roles.admins) - self.command_user = MockMember([self.admin_role]) - self.guild = MockGuild([self.admin_role]) - self.ctx = MockContext(bot=self.bot, author=self.command_user, guild=self.guild) - self.cog = jams.CodeJams(self.bot) - - async def test_too_small_amount_of_team_members_passed(self): - """Should `ctx.send` and exit early when too small amount of members.""" - for case in (1, 2): - with self.subTest(amount_of_members=case): - self.cog.create_channels = AsyncMock() - self.cog.add_roles = AsyncMock() - - self.ctx.reset_mock() - members = (MockMember() for _ in range(case)) - await self.cog.createteam(self.cog, self.ctx, "foo", members) - - self.ctx.send.assert_awaited_once() - self.cog.create_channels.assert_not_awaited() - self.cog.add_roles.assert_not_awaited() - - async def test_duplicate_members_provided(self): - """Should `ctx.send` and exit early because duplicate members provided and total there is only 1 member.""" - self.cog.create_channels = AsyncMock() - self.cog.add_roles = AsyncMock() - - member = MockMember() - await self.cog.createteam(self.cog, self.ctx, "foo", (member for _ in range(5))) - - self.ctx.send.assert_awaited_once() - self.cog.create_channels.assert_not_awaited() - self.cog.add_roles.assert_not_awaited() - - async def test_result_sending(self): - """Should call `ctx.send` when everything goes right.""" - self.cog.create_channels = AsyncMock() - self.cog.add_roles = AsyncMock() - - members = [MockMember() for _ in range(5)] - await self.cog.createteam(self.cog, self.ctx, "foo", members) - - self.cog.create_channels.assert_awaited_once() - self.cog.add_roles.assert_awaited_once() - self.ctx.send.assert_awaited_once() - - async def test_category_doesnt_exist(self): - """Should create a new code jam category.""" - subtests = ( - [], - [get_mock_category(jams.MAX_CHANNELS - 1, jams.CATEGORY_NAME)], - [get_mock_category(jams.MAX_CHANNELS - 2, "other")], - ) - - for categories in subtests: - self.guild.reset_mock() - self.guild.categories = categories - - with self.subTest(categories=categories): - actual_category = await self.cog.get_category(self.guild) - - self.guild.create_category_channel.assert_awaited_once() - category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"] - - self.assertFalse(category_overwrites[self.guild.default_role].read_messages) - self.assertTrue(category_overwrites[self.guild.me].read_messages) - self.assertEqual(self.guild.create_category_channel.return_value, actual_category) - - async def test_category_channel_exist(self): - """Should not try to create category channel.""" - expected_category = get_mock_category(jams.MAX_CHANNELS - 2, jams.CATEGORY_NAME) - self.guild.categories = [ - get_mock_category(jams.MAX_CHANNELS - 2, "other"), - expected_category, - get_mock_category(0, jams.CATEGORY_NAME), - ] - - actual_category = await self.cog.get_category(self.guild) - self.assertEqual(expected_category, actual_category) - - async def test_channel_overwrites(self): - """Should have correct permission overwrites for users and roles.""" - leader = MockMember() - members = [leader] + [MockMember() for _ in range(4)] - overwrites = self.cog.get_overwrites(members, self.guild) - - # Leader permission overwrites - self.assertTrue(overwrites[leader].manage_messages) - self.assertTrue(overwrites[leader].read_messages) - self.assertTrue(overwrites[leader].manage_webhooks) - self.assertTrue(overwrites[leader].connect) - - # Other members permission overwrites - for member in members[1:]: - self.assertTrue(overwrites[member].read_messages) - self.assertTrue(overwrites[member].connect) - - # Everyone and verified role overwrite - self.assertFalse(overwrites[self.guild.default_role].read_messages) - self.assertFalse(overwrites[self.guild.default_role].connect) - self.assertFalse(overwrites[self.guild.get_role(Roles.verified)].read_messages) - self.assertFalse(overwrites[self.guild.get_role(Roles.verified)].connect) - - async def test_team_channels_creation(self): - """Should create new voice and text channel for team.""" - members = [MockMember() for _ in range(5)] - - self.cog.get_overwrites = MagicMock() - self.cog.get_category = AsyncMock() - self.ctx.guild.create_text_channel.return_value = MockTextChannel(mention="foobar-channel") - actual = await self.cog.create_channels(self.guild, "my-team", members) - - self.assertEqual("foobar-channel", actual) - self.cog.get_overwrites.assert_called_once_with(members, self.guild) - self.cog.get_category.assert_awaited_once_with(self.guild) - - self.guild.create_text_channel.assert_awaited_once_with( - "my-team", - overwrites=self.cog.get_overwrites.return_value, - category=self.cog.get_category.return_value - ) - self.guild.create_voice_channel.assert_awaited_once_with( - "My Team", - overwrites=self.cog.get_overwrites.return_value, - category=self.cog.get_category.return_value - ) - - async def test_jam_roles_adding(self): - """Should add team leader role to leader and jam role to every team member.""" - leader_role = MockRole(name="Team Leader") - jam_role = MockRole(name="Jammer") - self.guild.get_role.side_effect = [leader_role, jam_role] - - leader = MockMember() - members = [leader] + [MockMember() for _ in range(4)] - await self.cog.add_roles(self.guild, members) - - leader.add_roles.assert_any_await(leader_role) - for member in members: - member.add_roles.assert_any_await(jam_role) - - -class CodeJamSetup(unittest.TestCase): - """Test for `setup` function of `CodeJam` cog.""" - - def test_setup(self): - """Should call `bot.add_cog`.""" - bot = MockBot() - jams.setup(bot) - bot.add_cog.assert_called_once() diff --git a/tests/bot/cogs/utils/test_snekbox.py b/tests/bot/cogs/utils/test_snekbox.py deleted file mode 100644 index 3e447f319..000000000 --- a/tests/bot/cogs/utils/test_snekbox.py +++ /dev/null @@ -1,409 +0,0 @@ -import asyncio -import logging -import unittest -from unittest.mock import AsyncMock, MagicMock, Mock, call, create_autospec, patch - -from discord.ext import commands - -from bot import constants -from bot.cogs.utils import snekbox -from bot.cogs.utils.snekbox import Snekbox -from tests.helpers import MockBot, MockContext, MockMessage, MockReaction, MockUser - - -class SnekboxTests(unittest.IsolatedAsyncioTestCase): - def setUp(self): - """Add mocked bot and cog to the instance.""" - self.bot = MockBot() - self.cog = Snekbox(bot=self.bot) - - async def test_post_eval(self): - """Post the eval code to the URLs.snekbox_eval_api endpoint.""" - resp = MagicMock() - resp.json = AsyncMock(return_value="return") - - context_manager = MagicMock() - context_manager.__aenter__.return_value = resp - self.bot.http_session.post.return_value = context_manager - - self.assertEqual(await self.cog.post_eval("import random"), "return") - self.bot.http_session.post.assert_called_with( - constants.URLs.snekbox_eval_api, - json={"input": "import random"}, - raise_for_status=True - ) - resp.json.assert_awaited_once() - - async def test_upload_output_reject_too_long(self): - """Reject output longer than MAX_PASTE_LEN.""" - result = await self.cog.upload_output("-" * (snekbox.MAX_PASTE_LEN + 1)) - self.assertEqual(result, "too long to upload") - - async def test_upload_output(self): - """Upload the eval output to the URLs.paste_service.format(key="documents") endpoint.""" - key = "MarkDiamond" - resp = MagicMock() - resp.json = AsyncMock(return_value={"key": key}) - - context_manager = MagicMock() - context_manager.__aenter__.return_value = resp - self.bot.http_session.post.return_value = context_manager - - self.assertEqual( - await self.cog.upload_output("My awesome output"), - constants.URLs.paste_service.format(key=key) - ) - self.bot.http_session.post.assert_called_with( - constants.URLs.paste_service.format(key="documents"), - data="My awesome output", - raise_for_status=True - ) - - async def test_upload_output_gracefully_fallback_if_exception_during_request(self): - """Output upload gracefully fallback if the upload fail.""" - resp = MagicMock() - resp.json = AsyncMock(side_effect=Exception) - - context_manager = MagicMock() - context_manager.__aenter__.return_value = resp - self.bot.http_session.post.return_value = context_manager - - log = logging.getLogger("bot.cogs.utils.snekbox") - with self.assertLogs(logger=log, level='ERROR'): - await self.cog.upload_output('My awesome output!') - - async def test_upload_output_gracefully_fallback_if_no_key_in_response(self): - """Output upload gracefully fallback if there is no key entry in the response body.""" - self.assertEqual((await self.cog.upload_output('My awesome output!')), None) - - def test_prepare_input(self): - cases = ( - ('print("Hello world!")', 'print("Hello world!")', 'non-formatted'), - ('`print("Hello world!")`', 'print("Hello world!")', 'one line code block'), - ('```\nprint("Hello world!")```', 'print("Hello world!")', 'multiline code block'), - ('```py\nprint("Hello world!")```', 'print("Hello world!")', 'multiline python code block'), - ) - for case, expected, testname in cases: - with self.subTest(msg=f'Extract code from {testname}.'): - self.assertEqual(self.cog.prepare_input(case), expected) - - def test_get_results_message(self): - """Return error and message according to the eval result.""" - cases = ( - ('ERROR', None, ('Your eval job has failed', 'ERROR')), - ('', 128 + snekbox.SIGKILL, ('Your eval job timed out or ran out of memory', '')), - ('', 255, ('Your eval job has failed', 'A fatal NsJail error occurred')) - ) - for stdout, returncode, expected in cases: - with self.subTest(stdout=stdout, returncode=returncode, expected=expected): - actual = self.cog.get_results_message({'stdout': stdout, 'returncode': returncode}) - self.assertEqual(actual, expected) - - @patch('bot.cogs.utils.snekbox.Signals', side_effect=ValueError) - def test_get_results_message_invalid_signal(self, mock_signals: Mock): - self.assertEqual( - self.cog.get_results_message({'stdout': '', 'returncode': 127}), - ('Your eval job has completed with return code 127', '') - ) - - @patch('bot.cogs.utils.snekbox.Signals') - def test_get_results_message_valid_signal(self, mock_signals: Mock): - mock_signals.return_value.name = 'SIGTEST' - self.assertEqual( - self.cog.get_results_message({'stdout': '', 'returncode': 127}), - ('Your eval job has completed with return code 127 (SIGTEST)', '') - ) - - def test_get_status_emoji(self): - """Return emoji according to the eval result.""" - cases = ( - (' ', -1, ':warning:'), - ('Hello world!', 0, ':white_check_mark:'), - ('Invalid beard size', -1, ':x:') - ) - for stdout, returncode, expected in cases: - with self.subTest(stdout=stdout, returncode=returncode, expected=expected): - actual = self.cog.get_status_emoji({'stdout': stdout, 'returncode': returncode}) - self.assertEqual(actual, expected) - - async def test_format_output(self): - """Test output formatting.""" - self.cog.upload_output = AsyncMock(return_value='https://testificate.com/') - - too_many_lines = ( - '001 | v\n002 | e\n003 | r\n004 | y\n005 | l\n006 | o\n' - '007 | n\n008 | g\n009 | b\n010 | e\n011 | a\n... (truncated - too many lines)' - ) - too_long_too_many_lines = ( - "\n".join( - f"{i:03d} | {line}" for i, line in enumerate(['verylongbeard' * 10] * 15, 1) - )[:1000] + "\n... (truncated - too long, too many lines)" - ) - - cases = ( - ('', ('[No output]', None), 'No output'), - ('My awesome output', ('My awesome output', None), 'One line output'), - ('<@', ("<@\u200B", None), r'Convert <@ to <@\u200B'), - ('" else mock_.__name__ + + with self.subTest(msg=subtest_msg): + _, mock_message = mock_() + await self.syncer._send_prompt(message_arg) + + calls = [mock.call(emoji) for emoji in self.syncer._REACTION_EMOJIS] + mock_message.add_reaction.assert_has_calls(calls) + + +class SyncerConfirmationTests(unittest.IsolatedAsyncioTestCase): + """Tests for waiting for a sync confirmation reaction on the prompt.""" + + def setUp(self): + self.bot = helpers.MockBot() + self.syncer = TestSyncer(self.bot) + self.core_dev_role = helpers.MockRole(id=constants.Roles.core_developers) + + @staticmethod + def get_message_reaction(emoji): + """Fixture to return a mock message an reaction from the given `emoji`.""" + message = helpers.MockMessage() + reaction = helpers.MockReaction(emoji=emoji, message=message) + + return message, reaction + + def test_reaction_check_for_valid_emoji_and_authors(self): + """Should return True if authors are identical or are a bot and a core dev, respectively.""" + user_subtests = ( + ( + helpers.MockMember(id=77), + helpers.MockMember(id=77), + "identical users", + ), + ( + helpers.MockMember(id=77, bot=True), + helpers.MockMember(id=43, roles=[self.core_dev_role]), + "bot author and core-dev reactor", + ), + ) + + for emoji in self.syncer._REACTION_EMOJIS: + for author, user, msg in user_subtests: + with self.subTest(author=author, user=user, emoji=emoji, msg=msg): + message, reaction = self.get_message_reaction(emoji) + ret_val = self.syncer._reaction_check(author, message, reaction, user) + + self.assertTrue(ret_val) + + def test_reaction_check_for_invalid_reactions(self): + """Should return False for invalid reaction events.""" + valid_emoji = self.syncer._REACTION_EMOJIS[0] + subtests = ( + ( + helpers.MockMember(id=77), + *self.get_message_reaction(valid_emoji), + helpers.MockMember(id=43, roles=[self.core_dev_role]), + "users are not identical", + ), + ( + helpers.MockMember(id=77, bot=True), + *self.get_message_reaction(valid_emoji), + helpers.MockMember(id=43), + "reactor lacks the core-dev role", + ), + ( + helpers.MockMember(id=77, bot=True, roles=[self.core_dev_role]), + *self.get_message_reaction(valid_emoji), + helpers.MockMember(id=77, bot=True, roles=[self.core_dev_role]), + "reactor is a bot", + ), + ( + helpers.MockMember(id=77), + helpers.MockMessage(id=95), + helpers.MockReaction(emoji=valid_emoji, message=helpers.MockMessage(id=26)), + helpers.MockMember(id=77), + "messages are not identical", + ), + ( + helpers.MockMember(id=77), + *self.get_message_reaction("InVaLiD"), + helpers.MockMember(id=77), + "emoji is invalid", + ), + ) + + for *args, msg in subtests: + kwargs = dict(zip(("author", "message", "reaction", "user"), args)) + with self.subTest(**kwargs, msg=msg): + ret_val = self.syncer._reaction_check(*args) + self.assertFalse(ret_val) + + async def test_wait_for_confirmation(self): + """The message should always be edited and only return True if the emoji is a check mark.""" + subtests = ( + (constants.Emojis.check_mark, True, None), + ("InVaLiD", False, None), + (None, False, asyncio.TimeoutError), + ) + + for emoji, ret_val, side_effect in subtests: + for bot in (True, False): + with self.subTest(emoji=emoji, ret_val=ret_val, side_effect=side_effect, bot=bot): + # Set up mocks + message = helpers.MockMessage() + member = helpers.MockMember(bot=bot) + + self.bot.wait_for.reset_mock() + self.bot.wait_for.return_value = (helpers.MockReaction(emoji=emoji), None) + self.bot.wait_for.side_effect = side_effect + + # Call the function + actual_return = await self.syncer._wait_for_confirmation(member, message) + + # Perform assertions + self.bot.wait_for.assert_called_once() + self.assertIn("reaction_add", self.bot.wait_for.call_args[0]) + + message.edit.assert_called_once() + kwargs = message.edit.call_args[1] + self.assertIn("content", kwargs) + + # Core devs should only be mentioned if the author is a bot. + if bot: + self.assertIn(self.syncer._CORE_DEV_MENTION, kwargs["content"]) + else: + self.assertNotIn(self.syncer._CORE_DEV_MENTION, kwargs["content"]) + + self.assertIs(actual_return, ret_val) + + +class SyncerSyncTests(unittest.IsolatedAsyncioTestCase): + """Tests for main function orchestrating the sync.""" + + def setUp(self): + self.bot = helpers.MockBot(user=helpers.MockMember(bot=True)) + self.syncer = TestSyncer(self.bot) + + async def test_sync_respects_confirmation_result(self): + """The sync should abort if confirmation fails and continue if confirmed.""" + mock_message = helpers.MockMessage() + subtests = ( + (True, mock_message), + (False, None), + ) + + for confirmed, message in subtests: + with self.subTest(confirmed=confirmed): + self.syncer._sync.reset_mock() + self.syncer._get_diff.reset_mock() + + diff = _Diff({1, 2, 3}, {4, 5}, None) + self.syncer._get_diff.return_value = diff + self.syncer._get_confirmation_result = mock.AsyncMock( + return_value=(confirmed, message) + ) + + guild = helpers.MockGuild() + await self.syncer.sync(guild) + + self.syncer._get_diff.assert_called_once_with(guild) + self.syncer._get_confirmation_result.assert_called_once() + + if confirmed: + self.syncer._sync.assert_called_once_with(diff) + else: + self.syncer._sync.assert_not_called() + + async def test_sync_diff_size(self): + """The diff size should be correctly calculated.""" + subtests = ( + (6, _Diff({1, 2}, {3, 4}, {5, 6})), + (5, _Diff({1, 2, 3}, None, {4, 5})), + (0, _Diff(None, None, None)), + (0, _Diff(set(), set(), set())), + ) + + for size, diff in subtests: + with self.subTest(size=size, diff=diff): + self.syncer._get_diff.reset_mock() + self.syncer._get_diff.return_value = diff + self.syncer._get_confirmation_result = mock.AsyncMock(return_value=(False, None)) + + guild = helpers.MockGuild() + await self.syncer.sync(guild) + + self.syncer._get_diff.assert_called_once_with(guild) + self.syncer._get_confirmation_result.assert_called_once() + self.assertEqual(self.syncer._get_confirmation_result.call_args[0][0], size) + + async def test_sync_message_edited(self): + """The message should be edited if one was sent, even if the sync has an API error.""" + subtests = ( + (None, None, False), + (helpers.MockMessage(), None, True), + (helpers.MockMessage(), ResponseCodeError(mock.MagicMock()), True), + ) + + for message, side_effect, should_edit in subtests: + with self.subTest(message=message, side_effect=side_effect, should_edit=should_edit): + self.syncer._sync.side_effect = side_effect + self.syncer._get_confirmation_result = mock.AsyncMock( + return_value=(True, message) + ) + + guild = helpers.MockGuild() + await self.syncer.sync(guild) + + if should_edit: + message.edit.assert_called_once() + self.assertIn("content", message.edit.call_args[1]) + + async def test_sync_confirmation_context_redirect(self): + """If ctx is given, a new message should be sent and author should be ctx's author.""" + mock_member = helpers.MockMember() + subtests = ( + (None, self.bot.user, None), + (helpers.MockContext(author=mock_member), mock_member, helpers.MockMessage()), + ) + + for ctx, author, message in subtests: + with self.subTest(ctx=ctx, author=author, message=message): + if ctx is not None: + ctx.send.return_value = message + + # Make sure `_get_diff` returns a MagicMock, not an AsyncMock + self.syncer._get_diff.return_value = mock.MagicMock() + + self.syncer._get_confirmation_result = mock.AsyncMock(return_value=(False, None)) + + guild = helpers.MockGuild() + await self.syncer.sync(guild, ctx) + + if ctx is not None: + ctx.send.assert_called_once() + + self.syncer._get_confirmation_result.assert_called_once() + self.assertEqual(self.syncer._get_confirmation_result.call_args[0][1], author) + self.assertEqual(self.syncer._get_confirmation_result.call_args[0][2], message) + + @mock.patch.object(constants.Sync, "max_diff", new=3) + async def test_confirmation_result_small_diff(self): + """Should always return True and the given message if the diff size is too small.""" + author = helpers.MockMember() + expected_message = helpers.MockMessage() + + for size in (3, 2): # pragma: no cover + with self.subTest(size=size): + self.syncer._send_prompt = mock.AsyncMock() + self.syncer._wait_for_confirmation = mock.AsyncMock() + + coro = self.syncer._get_confirmation_result(size, author, expected_message) + result, actual_message = await coro + + self.assertTrue(result) + self.assertEqual(actual_message, expected_message) + self.syncer._send_prompt.assert_not_called() + self.syncer._wait_for_confirmation.assert_not_called() + + @mock.patch.object(constants.Sync, "max_diff", new=3) + async def test_confirmation_result_large_diff(self): + """Should return True if confirmed and False if _send_prompt fails or aborted.""" + author = helpers.MockMember() + mock_message = helpers.MockMessage() + + subtests = ( + (True, mock_message, True, "confirmed"), + (False, None, False, "_send_prompt failed"), + (False, mock_message, False, "aborted"), + ) + + for expected_result, expected_message, confirmed, msg in subtests: # pragma: no cover + with self.subTest(msg=msg): + self.syncer._send_prompt = mock.AsyncMock(return_value=expected_message) + self.syncer._wait_for_confirmation = mock.AsyncMock(return_value=confirmed) + + coro = self.syncer._get_confirmation_result(4, author) + actual_result, actual_message = await coro + + self.syncer._send_prompt.assert_called_once_with(None) # message defaults to None + self.assertIs(actual_result, expected_result) + self.assertEqual(actual_message, expected_message) + + if expected_message: + self.syncer._wait_for_confirmation.assert_called_once_with( + author, expected_message + ) diff --git a/tests/bot/exts/backend/sync/test_cog.py b/tests/bot/exts/backend/sync/test_cog.py new file mode 100644 index 000000000..1b89564f2 --- /dev/null +++ b/tests/bot/exts/backend/sync/test_cog.py @@ -0,0 +1,416 @@ +import unittest +from unittest import mock + +import discord + +from bot import constants +from bot.api import ResponseCodeError +from bot.exts.backend import sync +from bot.exts.backend.sync._cog import Sync +from bot.exts.backend.sync._syncers import Syncer +from tests import helpers +from tests.base import CommandTestCase + + +class SyncExtensionTests(unittest.IsolatedAsyncioTestCase): + """Tests for the sync extension.""" + + @staticmethod + def test_extension_setup(): + """The Sync cog should be added.""" + bot = helpers.MockBot() + sync.setup(bot) + bot.add_cog.assert_called_once() + + +class SyncCogTestCase(unittest.IsolatedAsyncioTestCase): + """Base class for Sync cog tests. Sets up patches for syncers.""" + + def setUp(self): + self.bot = helpers.MockBot() + + self.role_syncer_patcher = mock.patch( + "bot.exts.backend.sync._syncers.RoleSyncer", + autospec=Syncer, + spec_set=True + ) + self.user_syncer_patcher = mock.patch( + "bot.exts.backend.sync._syncers.UserSyncer", + autospec=Syncer, + spec_set=True + ) + self.RoleSyncer = self.role_syncer_patcher.start() + self.UserSyncer = self.user_syncer_patcher.start() + + self.cog = Sync(self.bot) + + def tearDown(self): + self.role_syncer_patcher.stop() + self.user_syncer_patcher.stop() + + @staticmethod + def response_error(status: int) -> ResponseCodeError: + """Fixture to return a ResponseCodeError with the given status code.""" + response = mock.MagicMock() + response.status = status + + return ResponseCodeError(response) + + +class SyncCogTests(SyncCogTestCase): + """Tests for the Sync cog.""" + + @mock.patch.object(Sync, "sync_guild", new_callable=mock.MagicMock) + def test_sync_cog_init(self, sync_guild): + """Should instantiate syncers and run a sync for the guild.""" + # Reset because a Sync cog was already instantiated in setUp. + self.RoleSyncer.reset_mock() + self.UserSyncer.reset_mock() + self.bot.loop.create_task = mock.MagicMock() + + mock_sync_guild_coro = mock.MagicMock() + sync_guild.return_value = mock_sync_guild_coro + + Sync(self.bot) + + self.RoleSyncer.assert_called_once_with(self.bot) + self.UserSyncer.assert_called_once_with(self.bot) + sync_guild.assert_called_once_with() + self.bot.loop.create_task.assert_called_once_with(mock_sync_guild_coro) + + async def test_sync_cog_sync_guild(self): + """Roles and users should be synced only if a guild is successfully retrieved.""" + for guild in (helpers.MockGuild(), None): + with self.subTest(guild=guild): + self.bot.reset_mock() + self.cog.role_syncer.reset_mock() + self.cog.user_syncer.reset_mock() + + self.bot.get_guild = mock.MagicMock(return_value=guild) + + await self.cog.sync_guild() + + self.bot.wait_until_guild_available.assert_called_once() + self.bot.get_guild.assert_called_once_with(constants.Guild.id) + + if guild is None: + self.cog.role_syncer.sync.assert_not_called() + self.cog.user_syncer.sync.assert_not_called() + else: + self.cog.role_syncer.sync.assert_called_once_with(guild) + self.cog.user_syncer.sync.assert_called_once_with(guild) + + async def patch_user_helper(self, side_effect: BaseException) -> None: + """Helper to set a side effect for bot.api_client.patch and then assert it is called.""" + self.bot.api_client.patch.reset_mock(side_effect=True) + self.bot.api_client.patch.side_effect = side_effect + + user_id, updated_information = 5, {"key": 123} + await self.cog.patch_user(user_id, updated_information) + + self.bot.api_client.patch.assert_called_once_with( + f"bot/users/{user_id}", + json=updated_information, + ) + + async def test_sync_cog_patch_user(self): + """A PATCH request should be sent and 404 errors ignored.""" + for side_effect in (None, self.response_error(404)): + with self.subTest(side_effect=side_effect): + await self.patch_user_helper(side_effect) + + async def test_sync_cog_patch_user_non_404(self): + """A PATCH request should be sent and the error raised if it's not a 404.""" + with self.assertRaises(ResponseCodeError): + await self.patch_user_helper(self.response_error(500)) + + +class SyncCogListenerTests(SyncCogTestCase): + """Tests for the listeners of the Sync cog.""" + + def setUp(self): + super().setUp() + self.cog.patch_user = mock.AsyncMock(spec_set=self.cog.patch_user) + + self.guild_id_patcher = mock.patch("bot.exts.backend.sync._cog.constants.Guild.id", 5) + self.guild_id = self.guild_id_patcher.start() + + self.guild = helpers.MockGuild(id=self.guild_id) + self.other_guild = helpers.MockGuild(id=0) + + def tearDown(self): + self.guild_id_patcher.stop() + + async def test_sync_cog_on_guild_role_create(self): + """A POST request should be sent with the new role's data.""" + self.assertTrue(self.cog.on_guild_role_create.__cog_listener__) + + role_data = { + "colour": 49, + "id": 777, + "name": "rolename", + "permissions": 8, + "position": 23, + } + role = helpers.MockRole(**role_data, guild=self.guild) + await self.cog.on_guild_role_create(role) + + self.bot.api_client.post.assert_called_once_with("bot/roles", json=role_data) + + async def test_sync_cog_on_guild_role_create_ignores_guilds(self): + """Events from other guilds should be ignored.""" + role = helpers.MockRole(guild=self.other_guild) + await self.cog.on_guild_role_create(role) + self.bot.api_client.post.assert_not_awaited() + + async def test_sync_cog_on_guild_role_delete(self): + """A DELETE request should be sent.""" + self.assertTrue(self.cog.on_guild_role_delete.__cog_listener__) + + role = helpers.MockRole(id=99, guild=self.guild) + await self.cog.on_guild_role_delete(role) + + self.bot.api_client.delete.assert_called_once_with("bot/roles/99") + + async def test_sync_cog_on_guild_role_delete_ignores_guilds(self): + """Events from other guilds should be ignored.""" + role = helpers.MockRole(guild=self.other_guild) + await self.cog.on_guild_role_delete(role) + self.bot.api_client.delete.assert_not_awaited() + + async def test_sync_cog_on_guild_role_update(self): + """A PUT request should be sent if the colour, name, permissions, or position changes.""" + self.assertTrue(self.cog.on_guild_role_update.__cog_listener__) + + role_data = { + "colour": 49, + "id": 777, + "name": "rolename", + "permissions": 8, + "position": 23, + } + subtests = ( + (True, ("colour", "name", "permissions", "position")), + (False, ("hoist", "mentionable")), + ) + + for should_put, attributes in subtests: + for attribute in attributes: + with self.subTest(should_put=should_put, changed_attribute=attribute): + self.bot.api_client.put.reset_mock() + + after_role_data = role_data.copy() + after_role_data[attribute] = 876 + + before_role = helpers.MockRole(**role_data, guild=self.guild) + after_role = helpers.MockRole(**after_role_data, guild=self.guild) + + await self.cog.on_guild_role_update(before_role, after_role) + + if should_put: + self.bot.api_client.put.assert_called_once_with( + f"bot/roles/{after_role.id}", + json=after_role_data + ) + else: + self.bot.api_client.put.assert_not_called() + + async def test_sync_cog_on_guild_role_update_ignores_guilds(self): + """Events from other guilds should be ignored.""" + role = helpers.MockRole(guild=self.other_guild) + await self.cog.on_guild_role_update(role, role) + self.bot.api_client.put.assert_not_awaited() + + async def test_sync_cog_on_member_remove(self): + """Member should be patched to set in_guild as False.""" + self.assertTrue(self.cog.on_member_remove.__cog_listener__) + + member = helpers.MockMember(guild=self.guild) + await self.cog.on_member_remove(member) + + self.cog.patch_user.assert_called_once_with( + member.id, + json={"in_guild": False} + ) + + async def test_sync_cog_on_member_remove_ignores_guilds(self): + """Events from other guilds should be ignored.""" + member = helpers.MockMember(guild=self.other_guild) + await self.cog.on_member_remove(member) + self.cog.patch_user.assert_not_awaited() + + async def test_sync_cog_on_member_update_roles(self): + """Members should be patched if their roles have changed.""" + self.assertTrue(self.cog.on_member_update.__cog_listener__) + + # Roles are intentionally unsorted. + before_roles = [helpers.MockRole(id=12), helpers.MockRole(id=30), helpers.MockRole(id=20)] + before_member = helpers.MockMember(roles=before_roles, guild=self.guild) + after_member = helpers.MockMember(roles=before_roles[1:], guild=self.guild) + + await self.cog.on_member_update(before_member, after_member) + + data = {"roles": sorted(role.id for role in after_member.roles)} + self.cog.patch_user.assert_called_once_with(after_member.id, json=data) + + async def test_sync_cog_on_member_update_other(self): + """Members should not be patched if other attributes have changed.""" + self.assertTrue(self.cog.on_member_update.__cog_listener__) + + subtests = ( + ("activities", discord.Game("Pong"), discord.Game("Frogger")), + ("nick", "old nick", "new nick"), + ("status", discord.Status.online, discord.Status.offline), + ) + + for attribute, old_value, new_value in subtests: + with self.subTest(attribute=attribute): + self.cog.patch_user.reset_mock() + + before_member = helpers.MockMember(**{attribute: old_value}, guild=self.guild) + after_member = helpers.MockMember(**{attribute: new_value}, guild=self.guild) + + await self.cog.on_member_update(before_member, after_member) + + self.cog.patch_user.assert_not_called() + + async def test_sync_cog_on_member_update_ignores_guilds(self): + """Events from other guilds should be ignored.""" + member = helpers.MockMember(guild=self.other_guild) + await self.cog.on_member_update(member, member) + self.cog.patch_user.assert_not_awaited() + + async def test_sync_cog_on_user_update(self): + """A user should be patched only if the name, discriminator, or avatar changes.""" + self.assertTrue(self.cog.on_user_update.__cog_listener__) + + before_data = { + "name": "old name", + "discriminator": "1234", + "bot": False, + } + + subtests = ( + (True, "name", "name", "new name", "new name"), + (True, "discriminator", "discriminator", "8765", 8765), + (False, "bot", "bot", True, True), + ) + + for should_patch, attribute, api_field, value, api_value in subtests: + with self.subTest(attribute=attribute): + self.cog.patch_user.reset_mock() + + after_data = before_data.copy() + after_data[attribute] = value + before_user = helpers.MockUser(**before_data) + after_user = helpers.MockUser(**after_data) + + await self.cog.on_user_update(before_user, after_user) + + if should_patch: + self.cog.patch_user.assert_called_once() + + # Don't care if *all* keys are present; only the changed one is required + call_args = self.cog.patch_user.call_args + self.assertEqual(call_args.args[0], after_user.id) + self.assertIn("json", call_args.kwargs) + + self.assertIn("ignore_404", call_args.kwargs) + self.assertTrue(call_args.kwargs["ignore_404"]) + + json = call_args.kwargs["json"] + self.assertIn(api_field, json) + self.assertEqual(json[api_field], api_value) + else: + self.cog.patch_user.assert_not_called() + + async def on_member_join_helper(self, side_effect: Exception) -> dict: + """ + Helper to set `side_effect` for on_member_join and assert a PUT request was sent. + + The request data for the mock member is returned. All exceptions will be re-raised. + """ + member = helpers.MockMember( + discriminator="1234", + roles=[helpers.MockRole(id=22), helpers.MockRole(id=12)], + guild=self.guild, + ) + + data = { + "discriminator": int(member.discriminator), + "id": member.id, + "in_guild": True, + "name": member.name, + "roles": sorted(role.id for role in member.roles) + } + + self.bot.api_client.put.reset_mock(side_effect=True) + self.bot.api_client.put.side_effect = side_effect + + try: + await self.cog.on_member_join(member) + except Exception: + raise + finally: + self.bot.api_client.put.assert_called_once_with( + f"bot/users/{member.id}", + json=data + ) + + return data + + async def test_sync_cog_on_member_join(self): + """Should PUT user's data or POST it if the user doesn't exist.""" + for side_effect in (None, self.response_error(404)): + with self.subTest(side_effect=side_effect): + self.bot.api_client.post.reset_mock() + data = await self.on_member_join_helper(side_effect) + + if side_effect: + self.bot.api_client.post.assert_called_once_with("bot/users", json=data) + else: + self.bot.api_client.post.assert_not_called() + + async def test_sync_cog_on_member_join_non_404(self): + """ResponseCodeError should be re-raised if status code isn't a 404.""" + with self.assertRaises(ResponseCodeError): + await self.on_member_join_helper(self.response_error(500)) + + self.bot.api_client.post.assert_not_called() + + async def test_sync_cog_on_member_join_ignores_guilds(self): + """Events from other guilds should be ignored.""" + member = helpers.MockMember(guild=self.other_guild) + await self.cog.on_member_join(member) + self.bot.api_client.post.assert_not_awaited() + self.bot.api_client.put.assert_not_awaited() + + +class SyncCogCommandTests(SyncCogTestCase, CommandTestCase): + """Tests for the commands in the Sync cog.""" + + async def test_sync_roles_command(self): + """sync() should be called on the RoleSyncer.""" + ctx = helpers.MockContext() + await self.cog.sync_roles_command.callback(self.cog, ctx) + + self.cog.role_syncer.sync.assert_called_once_with(ctx.guild, ctx) + + async def test_sync_users_command(self): + """sync() should be called on the UserSyncer.""" + ctx = helpers.MockContext() + await self.cog.sync_users_command.callback(self.cog, ctx) + + self.cog.user_syncer.sync.assert_called_once_with(ctx.guild, ctx) + + async def test_commands_require_admin(self): + """The sync commands should only run if the author has the administrator permission.""" + cmds = ( + self.cog.sync_group, + self.cog.sync_roles_command, + self.cog.sync_users_command, + ) + + for cmd in cmds: + with self.subTest(cmd=cmd): + await self.assertHasPermissionsCheck(cmd, {"administrator": True}) diff --git a/tests/bot/exts/backend/sync/test_roles.py b/tests/bot/exts/backend/sync/test_roles.py new file mode 100644 index 000000000..7b9f40cad --- /dev/null +++ b/tests/bot/exts/backend/sync/test_roles.py @@ -0,0 +1,157 @@ +import unittest +from unittest import mock + +import discord + +from bot.exts.backend.sync._syncers import RoleSyncer, _Diff, _Role +from tests import helpers + + +def fake_role(**kwargs): + """Fixture to return a dictionary representing a role with default values set.""" + kwargs.setdefault("id", 9) + kwargs.setdefault("name", "fake role") + kwargs.setdefault("colour", 7) + kwargs.setdefault("permissions", 0) + kwargs.setdefault("position", 55) + + return kwargs + + +class RoleSyncerDiffTests(unittest.IsolatedAsyncioTestCase): + """Tests for determining differences between roles in the DB and roles in the Guild cache.""" + + def setUp(self): + self.bot = helpers.MockBot() + self.syncer = RoleSyncer(self.bot) + + @staticmethod + def get_guild(*roles): + """Fixture to return a guild object with the given roles.""" + guild = helpers.MockGuild() + guild.roles = [] + + for role in roles: + mock_role = helpers.MockRole(**role) + mock_role.colour = discord.Colour(role["colour"]) + mock_role.permissions = discord.Permissions(role["permissions"]) + guild.roles.append(mock_role) + + return guild + + async def test_empty_diff_for_identical_roles(self): + """No differences should be found if the roles in the guild and DB are identical.""" + self.bot.api_client.get.return_value = [fake_role()] + guild = self.get_guild(fake_role()) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = (set(), set(), set()) + + self.assertEqual(actual_diff, expected_diff) + + async def test_diff_for_updated_roles(self): + """Only updated roles should be added to the 'updated' set of the diff.""" + updated_role = fake_role(id=41, name="new") + + self.bot.api_client.get.return_value = [fake_role(id=41, name="old"), fake_role()] + guild = self.get_guild(updated_role, fake_role()) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = (set(), {_Role(**updated_role)}, set()) + + self.assertEqual(actual_diff, expected_diff) + + async def test_diff_for_new_roles(self): + """Only new roles should be added to the 'created' set of the diff.""" + new_role = fake_role(id=41, name="new") + + self.bot.api_client.get.return_value = [fake_role()] + guild = self.get_guild(fake_role(), new_role) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = ({_Role(**new_role)}, set(), set()) + + self.assertEqual(actual_diff, expected_diff) + + async def test_diff_for_deleted_roles(self): + """Only deleted roles should be added to the 'deleted' set of the diff.""" + deleted_role = fake_role(id=61, name="deleted") + + self.bot.api_client.get.return_value = [fake_role(), deleted_role] + guild = self.get_guild(fake_role()) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = (set(), set(), {_Role(**deleted_role)}) + + self.assertEqual(actual_diff, expected_diff) + + async def test_diff_for_new_updated_and_deleted_roles(self): + """When roles are added, updated, and removed, all of them are returned properly.""" + new = fake_role(id=41, name="new") + updated = fake_role(id=71, name="updated") + deleted = fake_role(id=61, name="deleted") + + self.bot.api_client.get.return_value = [ + fake_role(), + fake_role(id=71, name="updated name"), + deleted, + ] + guild = self.get_guild(fake_role(), new, updated) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = ({_Role(**new)}, {_Role(**updated)}, {_Role(**deleted)}) + + self.assertEqual(actual_diff, expected_diff) + + +class RoleSyncerSyncTests(unittest.IsolatedAsyncioTestCase): + """Tests for the API requests that sync roles.""" + + def setUp(self): + self.bot = helpers.MockBot() + self.syncer = RoleSyncer(self.bot) + + async def test_sync_created_roles(self): + """Only POST requests should be made with the correct payload.""" + roles = [fake_role(id=111), fake_role(id=222)] + + role_tuples = {_Role(**role) for role in roles} + diff = _Diff(role_tuples, set(), set()) + await self.syncer._sync(diff) + + calls = [mock.call("bot/roles", json=role) for role in roles] + self.bot.api_client.post.assert_has_calls(calls, any_order=True) + self.assertEqual(self.bot.api_client.post.call_count, len(roles)) + + self.bot.api_client.put.assert_not_called() + self.bot.api_client.delete.assert_not_called() + + async def test_sync_updated_roles(self): + """Only PUT requests should be made with the correct payload.""" + roles = [fake_role(id=111), fake_role(id=222)] + + role_tuples = {_Role(**role) for role in roles} + diff = _Diff(set(), role_tuples, set()) + await self.syncer._sync(diff) + + calls = [mock.call(f"bot/roles/{role['id']}", json=role) for role in roles] + self.bot.api_client.put.assert_has_calls(calls, any_order=True) + self.assertEqual(self.bot.api_client.put.call_count, len(roles)) + + self.bot.api_client.post.assert_not_called() + self.bot.api_client.delete.assert_not_called() + + async def test_sync_deleted_roles(self): + """Only DELETE requests should be made with the correct payload.""" + roles = [fake_role(id=111), fake_role(id=222)] + + role_tuples = {_Role(**role) for role in roles} + diff = _Diff(set(), set(), role_tuples) + await self.syncer._sync(diff) + + calls = [mock.call(f"bot/roles/{role['id']}") for role in roles] + self.bot.api_client.delete.assert_has_calls(calls, any_order=True) + self.assertEqual(self.bot.api_client.delete.call_count, len(roles)) + + self.bot.api_client.post.assert_not_called() + self.bot.api_client.put.assert_not_called() diff --git a/tests/bot/exts/backend/sync/test_users.py b/tests/bot/exts/backend/sync/test_users.py new file mode 100644 index 000000000..c0a1da35c --- /dev/null +++ b/tests/bot/exts/backend/sync/test_users.py @@ -0,0 +1,158 @@ +import unittest +from unittest import mock + +from bot.exts.backend.sync._syncers import UserSyncer, _Diff, _User +from tests import helpers + + +def fake_user(**kwargs): + """Fixture to return a dictionary representing a user with default values set.""" + kwargs.setdefault("id", 43) + kwargs.setdefault("name", "bob the test man") + kwargs.setdefault("discriminator", 1337) + kwargs.setdefault("roles", (666,)) + kwargs.setdefault("in_guild", True) + + return kwargs + + +class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): + """Tests for determining differences between users in the DB and users in the Guild cache.""" + + def setUp(self): + self.bot = helpers.MockBot() + self.syncer = UserSyncer(self.bot) + + @staticmethod + def get_guild(*members): + """Fixture to return a guild object with the given members.""" + guild = helpers.MockGuild() + guild.members = [] + + for member in members: + member = member.copy() + del member["in_guild"] + + mock_member = helpers.MockMember(**member) + mock_member.roles = [helpers.MockRole(id=role_id) for role_id in member["roles"]] + + guild.members.append(mock_member) + + return guild + + async def test_empty_diff_for_no_users(self): + """When no users are given, an empty diff should be returned.""" + guild = self.get_guild() + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = (set(), set(), None) + + self.assertEqual(actual_diff, expected_diff) + + async def test_empty_diff_for_identical_users(self): + """No differences should be found if the users in the guild and DB are identical.""" + self.bot.api_client.get.return_value = [fake_user()] + guild = self.get_guild(fake_user()) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = (set(), set(), None) + + self.assertEqual(actual_diff, expected_diff) + + async def test_diff_for_updated_users(self): + """Only updated users should be added to the 'updated' set of the diff.""" + updated_user = fake_user(id=99, name="new") + + self.bot.api_client.get.return_value = [fake_user(id=99, name="old"), fake_user()] + guild = self.get_guild(updated_user, fake_user()) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = (set(), {_User(**updated_user)}, None) + + self.assertEqual(actual_diff, expected_diff) + + async def test_diff_for_new_users(self): + """Only new users should be added to the 'created' set of the diff.""" + new_user = fake_user(id=99, name="new") + + self.bot.api_client.get.return_value = [fake_user()] + guild = self.get_guild(fake_user(), new_user) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = ({_User(**new_user)}, set(), None) + + self.assertEqual(actual_diff, expected_diff) + + async def test_diff_sets_in_guild_false_for_leaving_users(self): + """When a user leaves the guild, the `in_guild` flag is updated to `False`.""" + leaving_user = fake_user(id=63, in_guild=False) + + self.bot.api_client.get.return_value = [fake_user(), fake_user(id=63)] + guild = self.get_guild(fake_user()) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = (set(), {_User(**leaving_user)}, None) + + self.assertEqual(actual_diff, expected_diff) + + async def test_diff_for_new_updated_and_leaving_users(self): + """When users are added, updated, and removed, all of them are returned properly.""" + new_user = fake_user(id=99, name="new") + updated_user = fake_user(id=55, name="updated") + leaving_user = fake_user(id=63, in_guild=False) + + self.bot.api_client.get.return_value = [fake_user(), fake_user(id=55), fake_user(id=63)] + guild = self.get_guild(fake_user(), new_user, updated_user) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = ({_User(**new_user)}, {_User(**updated_user), _User(**leaving_user)}, None) + + self.assertEqual(actual_diff, expected_diff) + + async def test_empty_diff_for_db_users_not_in_guild(self): + """When the DB knows a user the guild doesn't, no difference is found.""" + self.bot.api_client.get.return_value = [fake_user(), fake_user(id=63, in_guild=False)] + guild = self.get_guild(fake_user()) + + actual_diff = await self.syncer._get_diff(guild) + expected_diff = (set(), set(), None) + + self.assertEqual(actual_diff, expected_diff) + + +class UserSyncerSyncTests(unittest.IsolatedAsyncioTestCase): + """Tests for the API requests that sync users.""" + + def setUp(self): + self.bot = helpers.MockBot() + self.syncer = UserSyncer(self.bot) + + async def test_sync_created_users(self): + """Only POST requests should be made with the correct payload.""" + users = [fake_user(id=111), fake_user(id=222)] + + user_tuples = {_User(**user) for user in users} + diff = _Diff(user_tuples, set(), None) + await self.syncer._sync(diff) + + calls = [mock.call("bot/users", json=user) for user in users] + self.bot.api_client.post.assert_has_calls(calls, any_order=True) + self.assertEqual(self.bot.api_client.post.call_count, len(users)) + + self.bot.api_client.put.assert_not_called() + self.bot.api_client.delete.assert_not_called() + + async def test_sync_updated_users(self): + """Only PUT requests should be made with the correct payload.""" + users = [fake_user(id=111), fake_user(id=222)] + + user_tuples = {_User(**user) for user in users} + diff = _Diff(set(), user_tuples, None) + await self.syncer._sync(diff) + + calls = [mock.call(f"bot/users/{user['id']}", json=user) for user in users] + self.bot.api_client.put.assert_has_calls(calls, any_order=True) + self.assertEqual(self.bot.api_client.put.call_count, len(users)) + + self.bot.api_client.post.assert_not_called() + self.bot.api_client.delete.assert_not_called() diff --git a/tests/bot/exts/backend/test_logging.py b/tests/bot/exts/backend/test_logging.py new file mode 100644 index 000000000..466f207d9 --- /dev/null +++ b/tests/bot/exts/backend/test_logging.py @@ -0,0 +1,32 @@ +import unittest +from unittest.mock import patch + +from bot import constants +from bot.exts.backend.logging import Logging +from tests.helpers import MockBot, MockTextChannel + + +class LoggingTests(unittest.IsolatedAsyncioTestCase): + """Test cases for connected login.""" + + def setUp(self): + self.bot = MockBot() + self.cog = Logging(self.bot) + self.dev_log = MockTextChannel(id=1234, name="dev-log") + + @patch("bot.exts.backend.logging.DEBUG_MODE", False) + async def test_debug_mode_false(self): + """Should send connected message to dev-log.""" + self.bot.get_channel.return_value = self.dev_log + + await self.cog.startup_greeting() + self.bot.wait_until_guild_available.assert_awaited_once_with() + self.bot.get_channel.assert_called_once_with(constants.Channels.dev_log) + self.dev_log.send.assert_awaited_once() + + @patch("bot.exts.backend.logging.DEBUG_MODE", True) + async def test_debug_mode_true(self): + """Should not send anything to dev-log.""" + await self.cog.startup_greeting() + self.bot.wait_until_guild_available.assert_awaited_once_with() + self.bot.get_channel.assert_not_called() diff --git a/tests/bot/exts/filters/__init__.py b/tests/bot/exts/filters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/exts/filters/test_antimalware.py b/tests/bot/exts/filters/test_antimalware.py new file mode 100644 index 000000000..960894e5c --- /dev/null +++ b/tests/bot/exts/filters/test_antimalware.py @@ -0,0 +1,165 @@ +import unittest +from unittest.mock import AsyncMock, Mock + +from discord import NotFound + +from bot.constants import Channels, STAFF_ROLES +from bot.exts.filters import antimalware +from tests.helpers import MockAttachment, MockBot, MockMessage, MockRole + + +class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): + """Test the AntiMalware cog.""" + + def setUp(self): + """Sets up fresh objects for each test.""" + self.bot = MockBot() + self.bot.filter_list_cache = { + "FILE_FORMAT.True": { + ".first": {}, + ".second": {}, + ".third": {}, + } + } + self.cog = antimalware.AntiMalware(self.bot) + self.message = MockMessage() + self.whitelist = [".first", ".second", ".third"] + + async def test_message_with_allowed_attachment(self): + """Messages with allowed extensions should not be deleted""" + attachment = MockAttachment(filename="python.first") + self.message.attachments = [attachment] + + await self.cog.on_message(self.message) + self.message.delete.assert_not_called() + + async def test_message_without_attachment(self): + """Messages without attachments should result in no action.""" + await self.cog.on_message(self.message) + self.message.delete.assert_not_called() + + async def test_direct_message_with_attachment(self): + """Direct messages should have no action taken.""" + attachment = MockAttachment(filename="python.disallowed") + self.message.attachments = [attachment] + self.message.guild = None + + await self.cog.on_message(self.message) + + self.message.delete.assert_not_called() + + async def test_message_with_illegal_extension_gets_deleted(self): + """A message containing an illegal extension should send an embed.""" + attachment = MockAttachment(filename="python.disallowed") + self.message.attachments = [attachment] + + await self.cog.on_message(self.message) + + self.message.delete.assert_called_once() + + async def test_message_send_by_staff(self): + """A message send by a member of staff should be ignored.""" + staff_role = MockRole(id=STAFF_ROLES[0]) + self.message.author.roles.append(staff_role) + attachment = MockAttachment(filename="python.disallowed") + self.message.attachments = [attachment] + + await self.cog.on_message(self.message) + + self.message.delete.assert_not_called() + + async def test_python_file_redirect_embed_description(self): + """A message containing a .py file should result in an embed redirecting the user to our paste site""" + attachment = MockAttachment(filename="python.py") + self.message.attachments = [attachment] + self.message.channel.send = AsyncMock() + + await self.cog.on_message(self.message) + self.message.channel.send.assert_called_once() + args, kwargs = self.message.channel.send.call_args + embed = kwargs.pop("embed") + + self.assertEqual(embed.description, antimalware.PY_EMBED_DESCRIPTION) + + async def test_txt_file_redirect_embed_description(self): + """A message containing a .txt file should result in the correct embed.""" + attachment = MockAttachment(filename="python.txt") + self.message.attachments = [attachment] + self.message.channel.send = AsyncMock() + antimalware.TXT_EMBED_DESCRIPTION = Mock() + antimalware.TXT_EMBED_DESCRIPTION.format.return_value = "test" + + await self.cog.on_message(self.message) + self.message.channel.send.assert_called_once() + args, kwargs = self.message.channel.send.call_args + embed = kwargs.pop("embed") + cmd_channel = self.bot.get_channel(Channels.bot_commands) + + self.assertEqual(embed.description, antimalware.TXT_EMBED_DESCRIPTION.format.return_value) + antimalware.TXT_EMBED_DESCRIPTION.format.assert_called_with(cmd_channel_mention=cmd_channel.mention) + + async def test_other_disallowed_extension_embed_description(self): + """Test the description for a non .py/.txt disallowed extension.""" + attachment = MockAttachment(filename="python.disallowed") + self.message.attachments = [attachment] + self.message.channel.send = AsyncMock() + antimalware.DISALLOWED_EMBED_DESCRIPTION = Mock() + antimalware.DISALLOWED_EMBED_DESCRIPTION.format.return_value = "test" + + await self.cog.on_message(self.message) + self.message.channel.send.assert_called_once() + args, kwargs = self.message.channel.send.call_args + embed = kwargs.pop("embed") + meta_channel = self.bot.get_channel(Channels.meta) + + self.assertEqual(embed.description, antimalware.DISALLOWED_EMBED_DESCRIPTION.format.return_value) + antimalware.DISALLOWED_EMBED_DESCRIPTION.format.assert_called_with( + joined_whitelist=", ".join(self.whitelist), + blocked_extensions_str=".disallowed", + meta_channel_mention=meta_channel.mention + ) + + async def test_removing_deleted_message_logs(self): + """Removing an already deleted message logs the correct message""" + attachment = MockAttachment(filename="python.disallowed") + self.message.attachments = [attachment] + self.message.delete = AsyncMock(side_effect=NotFound(response=Mock(status=""), message="")) + + with self.assertLogs(logger=antimalware.log, level="INFO"): + await self.cog.on_message(self.message) + self.message.delete.assert_called_once() + + async def test_message_with_illegal_attachment_logs(self): + """Deleting a message with an illegal attachment should result in a log.""" + attachment = MockAttachment(filename="python.disallowed") + self.message.attachments = [attachment] + + with self.assertLogs(logger=antimalware.log, level="INFO"): + await self.cog.on_message(self.message) + + async def test_get_disallowed_extensions(self): + """The return value should include all non-whitelisted extensions.""" + test_values = ( + ([], []), + (self.whitelist, []), + ([".first"], []), + ([".first", ".disallowed"], [".disallowed"]), + ([".disallowed"], [".disallowed"]), + ([".disallowed", ".illegal"], [".disallowed", ".illegal"]), + ) + + for extensions, expected_disallowed_extensions in test_values: + with self.subTest(extensions=extensions, expected_disallowed_extensions=expected_disallowed_extensions): + self.message.attachments = [MockAttachment(filename=f"filename{extension}") for extension in extensions] + disallowed_extensions = self.cog._get_disallowed_extensions(self.message) + self.assertCountEqual(disallowed_extensions, expected_disallowed_extensions) + + +class AntiMalwareSetupTests(unittest.TestCase): + """Tests setup of the `AntiMalware` cog.""" + + def test_setup(self): + """Setup of the extension should call add_cog.""" + bot = MockBot() + antimalware.setup(bot) + bot.add_cog.assert_called_once() diff --git a/tests/bot/exts/filters/test_antispam.py b/tests/bot/exts/filters/test_antispam.py new file mode 100644 index 000000000..6a0e4fded --- /dev/null +++ b/tests/bot/exts/filters/test_antispam.py @@ -0,0 +1,35 @@ +import unittest + +from bot.exts.filters import antispam + + +class AntispamConfigurationValidationTests(unittest.TestCase): + """Tests validation of the antispam cog configuration.""" + + def test_default_antispam_config_is_valid(self): + """The default antispam configuration is valid.""" + validation_errors = antispam.validate_config() + self.assertEqual(validation_errors, {}) + + def test_unknown_rule_returns_error(self): + """Configuring an unknown rule returns an error.""" + self.assertEqual( + antispam.validate_config({'invalid-rule': {}}), + {'invalid-rule': "`invalid-rule` is not recognized as an antispam rule."} + ) + + def test_missing_keys_returns_error(self): + """Not configuring required keys returns an error.""" + keys = (('interval', 'max'), ('max', 'interval')) + for configured_key, unconfigured_key in keys: + with self.subTest( + configured_key=configured_key, + unconfigured_key=unconfigured_key + ): + config = {'burst': {configured_key: 10}} + error = f"Key `{unconfigured_key}` is required but not set for rule `burst`" + + self.assertEqual( + antispam.validate_config(config), + {'burst': error} + ) diff --git a/tests/bot/exts/filters/test_security.py b/tests/bot/exts/filters/test_security.py new file mode 100644 index 000000000..c0c3baa42 --- /dev/null +++ b/tests/bot/exts/filters/test_security.py @@ -0,0 +1,54 @@ +import unittest +from unittest.mock import MagicMock + +from discord.ext.commands import NoPrivateMessage + +from bot.exts.filters import security +from tests.helpers import MockBot, MockContext + + +class SecurityCogTests(unittest.TestCase): + """Tests the `Security` cog.""" + + def setUp(self): + """Attach an instance of the cog to the class for tests.""" + self.bot = MockBot() + self.cog = security.Security(self.bot) + self.ctx = MockContext() + + def test_check_additions(self): + """The cog should add its checks after initialization.""" + self.bot.check.assert_any_call(self.cog.check_on_guild) + self.bot.check.assert_any_call(self.cog.check_not_bot) + + def test_check_not_bot_returns_false_for_humans(self): + """The bot check should return `True` when invoked with human authors.""" + self.ctx.author.bot = False + self.assertTrue(self.cog.check_not_bot(self.ctx)) + + def test_check_not_bot_returns_true_for_robots(self): + """The bot check should return `False` when invoked with robotic authors.""" + self.ctx.author.bot = True + self.assertFalse(self.cog.check_not_bot(self.ctx)) + + def test_check_on_guild_raises_when_outside_of_guild(self): + """When invoked outside of a guild, `check_on_guild` should cause an error.""" + self.ctx.guild = None + + with self.assertRaises(NoPrivateMessage, msg="This command cannot be used in private messages."): + self.cog.check_on_guild(self.ctx) + + def test_check_on_guild_returns_true_inside_of_guild(self): + """When invoked inside of a guild, `check_on_guild` should return `True`.""" + self.ctx.guild = "lemon's lemonade stand" + self.assertTrue(self.cog.check_on_guild(self.ctx)) + + +class SecurityCogLoadTests(unittest.TestCase): + """Tests loading the `Security` cog.""" + + def test_security_cog_load(self): + """Setup of the extension should call add_cog.""" + bot = MagicMock() + security.setup(bot) + bot.add_cog.assert_called_once() diff --git a/tests/bot/exts/filters/test_token_remover.py b/tests/bot/exts/filters/test_token_remover.py new file mode 100644 index 000000000..a0ff8a877 --- /dev/null +++ b/tests/bot/exts/filters/test_token_remover.py @@ -0,0 +1,310 @@ +import unittest +from re import Match +from unittest import mock +from unittest.mock import MagicMock + +from discord import Colour, NotFound + +from bot import constants +from bot.exts.filters import token_remover +from bot.exts.filters.token_remover import Token, TokenRemover +from bot.exts.moderation.modlog import ModLog +from tests.helpers import MockBot, MockMessage, autospec + + +class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): + """Tests the `TokenRemover` cog.""" + + def setUp(self): + """Adds the cog, a bot, and a message to the instance for usage in tests.""" + self.bot = MockBot() + self.cog = TokenRemover(bot=self.bot) + + self.msg = MockMessage(id=555, content="hello world") + self.msg.channel.mention = "#lemonade-stand" + self.msg.author.__str__ = MagicMock(return_value=self.msg.author.name) + self.msg.author.avatar_url_as.return_value = "picture-lemon.png" + + def test_is_valid_user_id_valid(self): + """Should consider user IDs valid if they decode entirely to ASCII digits.""" + ids = ( + "NDcyMjY1OTQzMDYyNDEzMzMy", + "NDc1MDczNjI5Mzk5NTQ3OTA0", + "NDY3MjIzMjMwNjUwNzc3NjQx", + ) + + for user_id in ids: + with self.subTest(user_id=user_id): + result = TokenRemover.is_valid_user_id(user_id) + self.assertTrue(result) + + def test_is_valid_user_id_invalid(self): + """Should consider non-digit and non-ASCII IDs invalid.""" + ids = ( + ("SGVsbG8gd29ybGQ", "non-digit ASCII"), + ("0J_RgNC40LLQtdGCINC80LjRgA", "cyrillic text"), + ("4pO14p6L4p6C4pG34p264pGl8J-EiOKSj-KCieKBsA", "Unicode digits"), + ("4oaA4oaB4oWh4oWi4Lyz4Lyq4Lyr4LG9", "Unicode numerals"), + ("8J2fjvCdn5nwnZ-k8J2fr_Cdn7rgravvvJngr6c", "Unicode decimals"), + ("{hello}[world]&(bye!)", "ASCII invalid Base64"), + ("Þíß-ï§-ňøẗ-våłìÐ", "Unicode invalid Base64"), + ) + + for user_id, msg in ids: + with self.subTest(msg=msg): + result = TokenRemover.is_valid_user_id(user_id) + self.assertFalse(result) + + def test_is_valid_timestamp_valid(self): + """Should consider timestamps valid if they're greater than the Discord epoch.""" + timestamps = ( + "XsyRkw", + "Xrim9Q", + "XsyR-w", + "XsySD_", + "Dn9r_A", + ) + + for timestamp in timestamps: + with self.subTest(timestamp=timestamp): + result = TokenRemover.is_valid_timestamp(timestamp) + self.assertTrue(result) + + def test_is_valid_timestamp_invalid(self): + """Should consider timestamps invalid if they're before Discord epoch or can't be parsed.""" + timestamps = ( + ("B4Yffw", "DISCORD_EPOCH - TOKEN_EPOCH - 1"), + ("ew", "123"), + ("AoIKgA", "42076800"), + ("{hello}[world]&(bye!)", "ASCII invalid Base64"), + ("Þíß-ï§-ňøẗ-våłìÐ", "Unicode invalid Base64"), + ) + + for timestamp, msg in timestamps: + with self.subTest(msg=msg): + result = TokenRemover.is_valid_timestamp(timestamp) + self.assertFalse(result) + + def test_mod_log_property(self): + """The `mod_log` property should ask the bot to return the `ModLog` cog.""" + self.bot.get_cog.return_value = 'lemon' + self.assertEqual(self.cog.mod_log, self.bot.get_cog.return_value) + self.bot.get_cog.assert_called_once_with('ModLog') + + async def test_on_message_edit_uses_on_message(self): + """The edit listener should delegate handling of the message to the normal listener.""" + self.cog.on_message = mock.create_autospec(self.cog.on_message, spec_set=True) + + await self.cog.on_message_edit(MockMessage(), self.msg) + self.cog.on_message.assert_awaited_once_with(self.msg) + + @autospec(TokenRemover, "find_token_in_message", "take_action") + async def test_on_message_takes_action(self, find_token_in_message, take_action): + """Should take action if a valid token is found when a message is sent.""" + cog = TokenRemover(self.bot) + found_token = "foobar" + find_token_in_message.return_value = found_token + + await cog.on_message(self.msg) + + find_token_in_message.assert_called_once_with(self.msg) + take_action.assert_awaited_once_with(cog, self.msg, found_token) + + @autospec(TokenRemover, "find_token_in_message", "take_action") + async def test_on_message_skips_missing_token(self, find_token_in_message, take_action): + """Shouldn't take action if a valid token isn't found when a message is sent.""" + cog = TokenRemover(self.bot) + find_token_in_message.return_value = False + + await cog.on_message(self.msg) + + find_token_in_message.assert_called_once_with(self.msg) + take_action.assert_not_awaited() + + @autospec(TokenRemover, "find_token_in_message") + async def test_on_message_ignores_dms_bots(self, find_token_in_message): + """Shouldn't parse a message if it is a DM or authored by a bot.""" + cog = TokenRemover(self.bot) + dm_msg = MockMessage(guild=None) + bot_msg = MockMessage(author=MagicMock(bot=True)) + + for msg in (dm_msg, bot_msg): + await cog.on_message(msg) + find_token_in_message.assert_not_called() + + @autospec("bot.exts.filters.token_remover", "TOKEN_RE") + def test_find_token_no_matches(self, token_re): + """None should be returned if the regex matches no tokens in a message.""" + token_re.finditer.return_value = () + + return_value = TokenRemover.find_token_in_message(self.msg) + + self.assertIsNone(return_value) + token_re.finditer.assert_called_once_with(self.msg.content) + + @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") + @autospec("bot.exts.filters.token_remover", "Token") + @autospec("bot.exts.filters.token_remover", "TOKEN_RE") + def test_find_token_valid_match(self, token_re, token_cls, is_valid_id, is_valid_timestamp): + """The first match with a valid user ID and timestamp should be returned as a `Token`.""" + matches = [ + mock.create_autospec(Match, spec_set=True, instance=True), + mock.create_autospec(Match, spec_set=True, instance=True), + ] + tokens = [ + mock.create_autospec(Token, spec_set=True, instance=True), + mock.create_autospec(Token, spec_set=True, instance=True), + ] + + token_re.finditer.return_value = matches + token_cls.side_effect = tokens + is_valid_id.side_effect = (False, True) # The 1st match will be invalid, 2nd one valid. + is_valid_timestamp.return_value = True + + return_value = TokenRemover.find_token_in_message(self.msg) + + self.assertEqual(tokens[1], return_value) + token_re.finditer.assert_called_once_with(self.msg.content) + + @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") + @autospec("bot.exts.filters.token_remover", "Token") + @autospec("bot.exts.filters.token_remover", "TOKEN_RE") + def test_find_token_invalid_matches(self, token_re, token_cls, is_valid_id, is_valid_timestamp): + """None should be returned if no matches have valid user IDs or timestamps.""" + token_re.finditer.return_value = [mock.create_autospec(Match, spec_set=True, instance=True)] + token_cls.return_value = mock.create_autospec(Token, spec_set=True, instance=True) + is_valid_id.return_value = False + is_valid_timestamp.return_value = False + + return_value = TokenRemover.find_token_in_message(self.msg) + + self.assertIsNone(return_value) + token_re.finditer.assert_called_once_with(self.msg.content) + + def test_regex_invalid_tokens(self): + """Messages without anything looking like a token are not matched.""" + tokens = ( + "", + "lemon wins", + "..", + "x.y", + "x.y.", + ".y.z", + ".y.", + "..z", + "x..z", + " . . ", + "\n.\n.\n", + "hellö.world.bye", + "base64.nötbåse64.morebase64", + "19jd3J.dfkm3d.€víł§tüff", + ) + + for token in tokens: + with self.subTest(token=token): + results = token_remover.TOKEN_RE.findall(token) + self.assertEqual(len(results), 0) + + def test_regex_valid_tokens(self): + """Messages that look like tokens should be matched.""" + # Don't worry, these tokens have been invalidated. + tokens = ( + "NDcyMjY1OTQzMDYy_DEzMz-y.XsyRkw.VXmErH7j511turNpfURmb0rVNm8", + "NDcyMjY1OTQzMDYyNDEzMzMy.Xrim9Q.Ysnu2wacjaKs7qnoo46S8Dm2us8", + "NDc1MDczNjI5Mzk5NTQ3OTA0.XsyR-w.sJf6omBPORBPju3WJEIAcwW9Zds", + "NDY3MjIzMjMwNjUwNzc3NjQx.XsySD_.s45jqDV_Iisn-symw0yDRrk_jf4", + ) + + for token in tokens: + with self.subTest(token=token): + results = token_remover.TOKEN_RE.fullmatch(token) + self.assertIsNotNone(results, f"{token} was not matched by the regex") + + def test_regex_matches_multiple_valid(self): + """Should support multiple matches in the middle of a string.""" + token_1 = "NDY3MjIzMjMwNjUwNzc3NjQx.XsyWGg.uFNEQPCc4ePwGh7egG8UicQssz8" + token_2 = "NDcyMjY1OTQzMDYyNDEzMzMy.XsyWMw.l8XPnDqb0lp-EiQ2g_0xVFT1pyc" + message = f"garbage {token_1} hello {token_2} world" + + results = token_remover.TOKEN_RE.finditer(message) + results = [match[0] for match in results] + self.assertCountEqual((token_1, token_2), results) + + @autospec("bot.exts.filters.token_remover", "LOG_MESSAGE") + def test_format_log_message(self, log_message): + """Should correctly format the log message with info from the message and token.""" + token = Token("NDY3MjIzMjMwNjUwNzc3NjQx", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") + log_message.format.return_value = "Howdy" + + return_value = TokenRemover.format_log_message(self.msg, token) + + self.assertEqual(return_value, log_message.format.return_value) + log_message.format.assert_called_once_with( + author=self.msg.author, + author_id=self.msg.author.id, + channel=self.msg.channel.mention, + user_id=token.user_id, + timestamp=token.timestamp, + hmac="x" * len(token.hmac), + ) + + @mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock) + @autospec("bot.exts.filters.token_remover", "log") + @autospec(TokenRemover, "format_log_message") + async def test_take_action(self, format_log_message, logger, mod_log_property): + """Should delete the message and send a mod log.""" + cog = TokenRemover(self.bot) + mod_log = mock.create_autospec(ModLog, spec_set=True, instance=True) + token = mock.create_autospec(Token, spec_set=True, instance=True) + log_msg = "testing123" + + mod_log_property.return_value = mod_log + format_log_message.return_value = log_msg + + await cog.take_action(self.msg, token) + + self.msg.delete.assert_called_once_with() + self.msg.channel.send.assert_called_once_with( + token_remover.DELETION_MESSAGE_TEMPLATE.format(mention=self.msg.author.mention) + ) + + format_log_message.assert_called_once_with(self.msg, token) + logger.debug.assert_called_with(log_msg) + self.bot.stats.incr.assert_called_once_with("tokens.removed_tokens") + + mod_log.ignore.assert_called_once_with(constants.Event.message_delete, self.msg.id) + mod_log.send_log_message.assert_called_once_with( + icon_url=constants.Icons.token_removed, + colour=Colour(constants.Colours.soft_red), + title="Token removed!", + text=log_msg, + thumbnail=self.msg.author.avatar_url_as.return_value, + channel_id=constants.Channels.mod_alerts + ) + + @mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock) + async def test_take_action_delete_failure(self, mod_log_property): + """Shouldn't send any messages if the token message can't be deleted.""" + cog = TokenRemover(self.bot) + mod_log_property.return_value = mock.create_autospec(ModLog, spec_set=True, instance=True) + self.msg.delete.side_effect = NotFound(MagicMock(), MagicMock()) + + token = mock.create_autospec(Token, spec_set=True, instance=True) + await cog.take_action(self.msg, token) + + self.msg.delete.assert_called_once_with() + self.msg.channel.send.assert_not_awaited() + + +class TokenRemoverExtensionTests(unittest.TestCase): + """Tests for the token_remover extension.""" + + @autospec("bot.exts.filters.token_remover", "TokenRemover") + def test_extension_setup(self, cog): + """The TokenRemover cog should be added.""" + bot = MockBot() + token_remover.setup(bot) + + cog.assert_called_once_with(bot) + bot.add_cog.assert_called_once() + self.assertTrue(isinstance(bot.add_cog.call_args.args[0], TokenRemover)) diff --git a/tests/bot/exts/info/__init__.py b/tests/bot/exts/info/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py new file mode 100644 index 000000000..be47d42ef --- /dev/null +++ b/tests/bot/exts/info/test_information.py @@ -0,0 +1,584 @@ +import asyncio +import textwrap +import unittest +import unittest.mock + +import discord + +from bot import constants +from bot.exts.info import information +from bot.utils.checks import InWhitelistCheckFailure +from tests import helpers + +COG_PATH = "bot.exts.info.information.Information" + + +class InformationCogTests(unittest.TestCase): + """Tests the Information cog.""" + + @classmethod + def setUpClass(cls): + cls.moderator_role = helpers.MockRole(name="Moderator", id=constants.Roles.moderators) + + def setUp(self): + """Sets up fresh objects for each test.""" + self.bot = helpers.MockBot() + + self.cog = information.Information(self.bot) + + self.ctx = helpers.MockContext() + self.ctx.author.roles.append(self.moderator_role) + + def test_roles_command_command(self): + """Test if the `role_info` command correctly returns the `moderator_role`.""" + self.ctx.guild.roles.append(self.moderator_role) + + self.cog.roles_info.can_run = unittest.mock.AsyncMock() + self.cog.roles_info.can_run.return_value = True + + coroutine = self.cog.roles_info.callback(self.cog, self.ctx) + + self.assertIsNone(asyncio.run(coroutine)) + self.ctx.send.assert_called_once() + + _, kwargs = self.ctx.send.call_args + embed = kwargs.pop('embed') + + self.assertEqual(embed.title, "Role information (Total 1 role)") + self.assertEqual(embed.colour, discord.Colour.blurple()) + self.assertEqual(embed.description, f"\n`{self.moderator_role.id}` - {self.moderator_role.mention}\n") + + def test_role_info_command(self): + """Tests the `role info` command.""" + dummy_role = helpers.MockRole( + name="Dummy", + id=112233445566778899, + colour=discord.Colour.blurple(), + position=10, + members=[self.ctx.author], + permissions=discord.Permissions(0) + ) + + admin_role = helpers.MockRole( + name="Admins", + id=998877665544332211, + colour=discord.Colour.red(), + position=3, + members=[self.ctx.author], + permissions=discord.Permissions(0), + ) + + self.ctx.guild.roles.append([dummy_role, admin_role]) + + self.cog.role_info.can_run = unittest.mock.AsyncMock() + self.cog.role_info.can_run.return_value = True + + coroutine = self.cog.role_info.callback(self.cog, self.ctx, dummy_role, admin_role) + + self.assertIsNone(asyncio.run(coroutine)) + + self.assertEqual(self.ctx.send.call_count, 2) + + (_, dummy_kwargs), (_, admin_kwargs) = self.ctx.send.call_args_list + + dummy_embed = dummy_kwargs["embed"] + admin_embed = admin_kwargs["embed"] + + self.assertEqual(dummy_embed.title, "Dummy info") + self.assertEqual(dummy_embed.colour, discord.Colour.blurple()) + + self.assertEqual(dummy_embed.fields[0].value, str(dummy_role.id)) + self.assertEqual(dummy_embed.fields[1].value, f"#{dummy_role.colour.value:0>6x}") + self.assertEqual(dummy_embed.fields[2].value, "0.63 0.48 218") + self.assertEqual(dummy_embed.fields[3].value, "1") + self.assertEqual(dummy_embed.fields[4].value, "10") + self.assertEqual(dummy_embed.fields[5].value, "0") + + self.assertEqual(admin_embed.title, "Admins info") + self.assertEqual(admin_embed.colour, discord.Colour.red()) + + @unittest.mock.patch('bot.exts.info.information.time_since') + def test_server_info_command(self, time_since_patch): + time_since_patch.return_value = '2 days ago' + + self.ctx.guild = helpers.MockGuild( + features=('lemons', 'apples'), + region="The Moon", + roles=[self.moderator_role], + channels=[ + discord.TextChannel( + state={}, + guild=self.ctx.guild, + data={'id': 42, 'name': 'lemons-offering', 'position': 22, 'type': 'text'} + ), + discord.CategoryChannel( + state={}, + guild=self.ctx.guild, + data={'id': 5125, 'name': 'the-lemon-collection', 'position': 22, 'type': 'category'} + ), + discord.VoiceChannel( + state={}, + guild=self.ctx.guild, + data={'id': 15290, 'name': 'listen-to-lemon', 'position': 22, 'type': 'voice'} + ) + ], + members=[ + *(helpers.MockMember(status=discord.Status.online) for _ in range(2)), + *(helpers.MockMember(status=discord.Status.idle) for _ in range(1)), + *(helpers.MockMember(status=discord.Status.dnd) for _ in range(4)), + *(helpers.MockMember(status=discord.Status.offline) for _ in range(3)), + ], + member_count=1_234, + icon_url='a-lemon.jpg', + ) + + coroutine = self.cog.server_info.callback(self.cog, self.ctx) + self.assertIsNone(asyncio.run(coroutine)) + + time_since_patch.assert_called_once_with(self.ctx.guild.created_at, precision='days') + _, kwargs = self.ctx.send.call_args + embed = kwargs.pop('embed') + self.assertEqual(embed.colour, discord.Colour.blurple()) + self.assertEqual( + embed.description, + textwrap.dedent( + f""" + **Server information** + Created: {time_since_patch.return_value} + Voice region: {self.ctx.guild.region} + Features: {', '.join(self.ctx.guild.features)} + + **Channel counts** + Category channels: 1 + Text channels: 1 + Voice channels: 1 + Staff channels: 0 + + **Member counts** + Members: {self.ctx.guild.member_count:,} + Staff members: 0 + Roles: {len(self.ctx.guild.roles)} + + **Member statuses** + {constants.Emojis.status_online} 2 + {constants.Emojis.status_idle} 1 + {constants.Emojis.status_dnd} 4 + {constants.Emojis.status_offline} 3 + """ + ) + ) + self.assertEqual(embed.thumbnail.url, 'a-lemon.jpg') + + +class UserInfractionHelperMethodTests(unittest.TestCase): + """Tests for the helper methods of the `!user` command.""" + + def setUp(self): + """Common set-up steps done before for each test.""" + self.bot = helpers.MockBot() + self.bot.api_client.get = unittest.mock.AsyncMock() + self.cog = information.Information(self.bot) + self.member = helpers.MockMember(id=1234) + + def test_user_command_helper_method_get_requests(self): + """The helper methods should form the correct get requests.""" + test_values = ( + { + "helper_method": self.cog.basic_user_infraction_counts, + "expected_args": ("bot/infractions", {'hidden': 'False', 'user__id': str(self.member.id)}), + }, + { + "helper_method": self.cog.expanded_user_infraction_counts, + "expected_args": ("bot/infractions", {'user__id': str(self.member.id)}), + }, + { + "helper_method": self.cog.user_nomination_counts, + "expected_args": ("bot/nominations", {'user__id': str(self.member.id)}), + }, + ) + + for test_value in test_values: + helper_method = test_value["helper_method"] + endpoint, params = test_value["expected_args"] + + with self.subTest(method=helper_method, endpoint=endpoint, params=params): + asyncio.run(helper_method(self.member)) + self.bot.api_client.get.assert_called_once_with(endpoint, params=params) + self.bot.api_client.get.reset_mock() + + def _method_subtests(self, method, test_values, default_header): + """Helper method that runs the subtests for the different helper methods.""" + for test_value in test_values: + api_response = test_value["api response"] + expected_lines = test_value["expected_lines"] + + with self.subTest(method=method, api_response=api_response, expected_lines=expected_lines): + self.bot.api_client.get.return_value = api_response + + expected_output = "\n".join(default_header + expected_lines) + actual_output = asyncio.run(method(self.member)) + + self.assertEqual(expected_output, actual_output) + + def test_basic_user_infraction_counts_returns_correct_strings(self): + """The method should correctly list both the total and active number of non-hidden infractions.""" + test_values = ( + # No infractions means zero counts + { + "api response": [], + "expected_lines": ["Total: 0", "Active: 0"], + }, + # Simple, single-infraction dictionaries + { + "api response": [{"type": "ban", "active": True}], + "expected_lines": ["Total: 1", "Active: 1"], + }, + { + "api response": [{"type": "ban", "active": False}], + "expected_lines": ["Total: 1", "Active: 0"], + }, + # Multiple infractions with various `active` status + { + "api response": [ + {"type": "ban", "active": True}, + {"type": "kick", "active": False}, + {"type": "ban", "active": True}, + {"type": "ban", "active": False}, + ], + "expected_lines": ["Total: 4", "Active: 2"], + }, + ) + + header = ["**Infractions**"] + + self._method_subtests(self.cog.basic_user_infraction_counts, test_values, header) + + def test_expanded_user_infraction_counts_returns_correct_strings(self): + """The method should correctly list the total and active number of all infractions split by infraction type.""" + test_values = ( + { + "api response": [], + "expected_lines": ["This user has never received an infraction."], + }, + # Shows non-hidden inactive infraction as expected + { + "api response": [{"type": "kick", "active": False, "hidden": False}], + "expected_lines": ["Kicks: 1"], + }, + # Shows non-hidden active infraction as expected + { + "api response": [{"type": "mute", "active": True, "hidden": False}], + "expected_lines": ["Mutes: 1 (1 active)"], + }, + # Shows hidden inactive infraction as expected + { + "api response": [{"type": "superstar", "active": False, "hidden": True}], + "expected_lines": ["Superstars: 1"], + }, + # Shows hidden active infraction as expected + { + "api response": [{"type": "ban", "active": True, "hidden": True}], + "expected_lines": ["Bans: 1 (1 active)"], + }, + # Correctly displays tally of multiple infractions of mixed properties in alphabetical order + { + "api response": [ + {"type": "kick", "active": False, "hidden": True}, + {"type": "ban", "active": True, "hidden": True}, + {"type": "superstar", "active": True, "hidden": True}, + {"type": "mute", "active": True, "hidden": True}, + {"type": "ban", "active": False, "hidden": False}, + {"type": "note", "active": False, "hidden": True}, + {"type": "note", "active": False, "hidden": True}, + {"type": "warn", "active": False, "hidden": False}, + {"type": "note", "active": False, "hidden": True}, + ], + "expected_lines": [ + "Bans: 2 (1 active)", + "Kicks: 1", + "Mutes: 1 (1 active)", + "Notes: 3", + "Superstars: 1 (1 active)", + "Warns: 1", + ], + }, + ) + + header = ["**Infractions**"] + + self._method_subtests(self.cog.expanded_user_infraction_counts, test_values, header) + + def test_user_nomination_counts_returns_correct_strings(self): + """The method should list the number of active and historical nominations for the user.""" + test_values = ( + { + "api response": [], + "expected_lines": ["This user has never been nominated."], + }, + { + "api response": [{'active': True}], + "expected_lines": ["This user is **currently** nominated (1 nomination in total)."], + }, + { + "api response": [{'active': True}, {'active': False}], + "expected_lines": ["This user is **currently** nominated (2 nominations in total)."], + }, + { + "api response": [{'active': False}], + "expected_lines": ["This user has 1 historical nomination, but is currently not nominated."], + }, + { + "api response": [{'active': False}, {'active': False}], + "expected_lines": ["This user has 2 historical nominations, but is currently not nominated."], + }, + + ) + + header = ["**Nominations**"] + + self._method_subtests(self.cog.user_nomination_counts, test_values, header) + + +@unittest.mock.patch("bot.exts.info.information.time_since", new=unittest.mock.MagicMock(return_value="1 year ago")) +@unittest.mock.patch("bot.exts.info.information.constants.MODERATION_CHANNELS", new=[50]) +class UserEmbedTests(unittest.TestCase): + """Tests for the creation of the `!user` embed.""" + + def setUp(self): + """Common set-up steps done before for each test.""" + self.bot = helpers.MockBot() + self.bot.api_client.get = unittest.mock.AsyncMock() + self.cog = information.Information(self.bot) + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self): + """The embed should use the string representation of the user if they don't have a nick.""" + ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) + user = helpers.MockMember() + user.nick = None + user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") + + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + self.assertEqual(embed.title, "Mr. Hemlock") + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + def test_create_user_embed_uses_nick_in_title_if_available(self): + """The embed should use the nick if it's available.""" + ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) + user = helpers.MockMember() + user.nick = "Cat lover" + user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") + + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)") + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + def test_create_user_embed_ignores_everyone_role(self): + """Created `!user` embeds should not contain mention of the @everyone-role.""" + ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) + admins_role = helpers.MockRole(name='Admins') + admins_role.colour = 100 + + # A `MockMember` has the @Everyone role by default; we add the Admins to that. + user = helpers.MockMember(roles=[admins_role], top_role=admins_role) + + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + self.assertIn("&Admins", embed.description) + self.assertNotIn("&Everyone", embed.description) + + @unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=unittest.mock.AsyncMock) + @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=unittest.mock.AsyncMock) + def test_create_user_embed_expanded_information_in_moderation_channels(self, nomination_counts, infraction_counts): + """The embed should contain expanded infractions and nomination info in mod channels.""" + ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=50)) + + moderators_role = helpers.MockRole(name='Moderators') + moderators_role.colour = 100 + + infraction_counts.return_value = "expanded infractions info" + nomination_counts.return_value = "nomination info" + + user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + infraction_counts.assert_called_once_with(user) + nomination_counts.assert_called_once_with(user) + + self.assertEqual( + textwrap.dedent(f""" + **User Information** + Created: {"1 year ago"} + Profile: {user.mention} + ID: {user.id} + + **Member Information** + Joined: {"1 year ago"} + Roles: &Moderators + + expanded infractions info + + nomination info + """).strip(), + embed.description + ) + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=unittest.mock.AsyncMock) + def test_create_user_embed_basic_information_outside_of_moderation_channels(self, infraction_counts): + """The embed should contain only basic infraction data outside of mod channels.""" + ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=100)) + + moderators_role = helpers.MockRole(name='Moderators') + moderators_role.colour = 100 + + infraction_counts.return_value = "basic infractions info" + + user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + infraction_counts.assert_called_once_with(user) + + self.assertEqual( + textwrap.dedent(f""" + **User Information** + Created: {"1 year ago"} + Profile: {user.mention} + ID: {user.id} + + **Member Information** + Joined: {"1 year ago"} + Roles: &Moderators + + basic infractions info + """).strip(), + embed.description + ) + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self): + """The embed should be created with the colour of the top role, if a top role is available.""" + ctx = helpers.MockContext() + + moderators_role = helpers.MockRole(name='Moderators') + moderators_role.colour = 100 + + user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + self.assertEqual(embed.colour, discord.Colour(moderators_role.colour)) + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self): + """The embed should be created with a blurple colour if the user has no assigned roles.""" + ctx = helpers.MockContext() + + user = helpers.MockMember(id=217) + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + self.assertEqual(embed.colour, discord.Colour.blurple()) + + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self): + """The embed thumbnail should be set to the user's avatar in `png` format.""" + ctx = helpers.MockContext() + + user = helpers.MockMember(id=217) + user.avatar_url_as.return_value = "avatar url" + embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + + user.avatar_url_as.assert_called_once_with(static_format="png") + self.assertEqual(embed.thumbnail.url, "avatar url") + + +@unittest.mock.patch("bot.exts.info.information.constants") +class UserCommandTests(unittest.TestCase): + """Tests for the `!user` command.""" + + def setUp(self): + """Set up steps executed before each test is run.""" + self.bot = helpers.MockBot() + self.cog = information.Information(self.bot) + + self.moderator_role = helpers.MockRole(name="Moderators", id=2, position=10) + self.flautist_role = helpers.MockRole(name="Flautists", id=3, position=2) + self.bassist_role = helpers.MockRole(name="Bassists", id=4, position=3) + + self.author = helpers.MockMember(id=1, name="syntaxaire") + self.moderator = helpers.MockMember(id=2, name="riffautae", roles=[self.moderator_role]) + self.target = helpers.MockMember(id=3, name="__fluzz__") + + def test_regular_member_cannot_target_another_member(self, constants): + """A regular user should not be able to use `!user` targeting another user.""" + constants.MODERATION_ROLES = [self.moderator_role.id] + + ctx = helpers.MockContext(author=self.author) + + asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target)) + + ctx.send.assert_called_once_with("You may not use this command on users other than yourself.") + + def test_regular_member_cannot_use_command_outside_of_bot_commands(self, constants): + """A regular user should not be able to use this command outside of bot-commands.""" + constants.MODERATION_ROLES = [self.moderator_role.id] + constants.STAFF_ROLES = [self.moderator_role.id] + constants.Channels.bot_commands = 50 + + ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=100)) + + msg = "Sorry, but you may only use this command within <#50>." + with self.assertRaises(InWhitelistCheckFailure, msg=msg): + asyncio.run(self.cog.user_info.callback(self.cog, ctx)) + + @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed") + def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants): + """A regular user should be allowed to use `!user` targeting themselves in bot-commands.""" + constants.STAFF_ROLES = [self.moderator_role.id] + constants.Channels.bot_commands = 50 + + ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) + + asyncio.run(self.cog.user_info.callback(self.cog, ctx)) + + create_embed.assert_called_once_with(ctx, self.author) + ctx.send.assert_called_once() + + @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed") + def test_regular_user_can_explicitly_target_themselves(self, create_embed, constants): + """A user should target itself with `!user` when a `user` argument was not provided.""" + constants.STAFF_ROLES = [self.moderator_role.id] + constants.Channels.bot_commands = 50 + + ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) + + asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.author)) + + create_embed.assert_called_once_with(ctx, self.author) + ctx.send.assert_called_once() + + @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed") + def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants): + """Staff members should be able to bypass the bot-commands channel restriction.""" + constants.STAFF_ROLES = [self.moderator_role.id] + constants.Channels.bot_commands = 50 + + ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=200)) + + asyncio.run(self.cog.user_info.callback(self.cog, ctx)) + + create_embed.assert_called_once_with(ctx, self.moderator) + ctx.send.assert_called_once() + + @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed") + def test_moderators_can_target_another_member(self, create_embed, constants): + """A moderator should be able to use `!user` targeting another user.""" + constants.MODERATION_ROLES = [self.moderator_role.id] + constants.STAFF_ROLES = [self.moderator_role.id] + + ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=50)) + + asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target)) + + create_embed.assert_called_once_with(ctx, self.target) + ctx.send.assert_called_once() diff --git a/tests/bot/exts/moderation/__init__.py b/tests/bot/exts/moderation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/exts/moderation/infraction/__init__.py b/tests/bot/exts/moderation/infraction/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py new file mode 100644 index 000000000..be1b649e1 --- /dev/null +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -0,0 +1,55 @@ +import textwrap +import unittest +from unittest.mock import AsyncMock, Mock, patch + +from bot.exts.moderation.infraction.infractions import Infractions +from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole + + +class TruncationTests(unittest.IsolatedAsyncioTestCase): + """Tests for ban and kick command reason truncation.""" + + def setUp(self): + self.bot = MockBot() + self.cog = Infractions(self.bot) + self.user = MockMember(id=1234, top_role=MockRole(id=3577, position=10)) + self.target = MockMember(id=1265, top_role=MockRole(id=9876, position=0)) + self.guild = MockGuild(id=4567) + self.ctx = MockContext(bot=self.bot, author=self.user, guild=self.guild) + + @patch("bot.exts.moderation.infraction._utils.get_active_infraction") + @patch("bot.exts.moderation.infraction._utils.post_infraction") + async def test_apply_ban_reason_truncation(self, post_infraction_mock, get_active_mock): + """Should truncate reason for `ctx.guild.ban`.""" + get_active_mock.return_value = None + post_infraction_mock.return_value = {"foo": "bar"} + + self.cog.apply_infraction = AsyncMock() + self.bot.get_cog.return_value = AsyncMock() + self.cog.mod_log.ignore = Mock() + self.ctx.guild.ban = Mock() + + await self.cog.apply_ban(self.ctx, self.target, "foo bar" * 3000) + self.ctx.guild.ban.assert_called_once_with( + self.target, + reason=textwrap.shorten("foo bar" * 3000, 512, placeholder="..."), + delete_message_days=0 + ) + self.cog.apply_infraction.assert_awaited_once_with( + self.ctx, {"foo": "bar"}, self.target, self.ctx.guild.ban.return_value + ) + + @patch("bot.exts.moderation.infraction._utils.post_infraction") + async def test_apply_kick_reason_truncation(self, post_infraction_mock): + """Should truncate reason for `Member.kick`.""" + post_infraction_mock.return_value = {"foo": "bar"} + + self.cog.apply_infraction = AsyncMock() + self.cog.mod_log.ignore = Mock() + self.target.kick = Mock() + + await self.cog.apply_kick(self.ctx, self.target, "foo bar" * 3000) + self.target.kick.assert_called_once_with(reason=textwrap.shorten("foo bar" * 3000, 512, placeholder="...")) + self.cog.apply_infraction.assert_awaited_once_with( + self.ctx, {"foo": "bar"}, self.target, self.target.kick.return_value + ) diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py new file mode 100644 index 000000000..cbf7f7bcf --- /dev/null +++ b/tests/bot/exts/moderation/test_incidents.py @@ -0,0 +1,770 @@ +import asyncio +import enum +import logging +import typing as t +import unittest +from unittest.mock import AsyncMock, MagicMock, call, patch + +import aiohttp +import discord + +from bot.constants import Colours +from bot.exts.moderation import incidents +from tests.helpers import ( + MockAsyncWebhook, + MockAttachment, + MockBot, + MockMember, + MockMessage, + MockReaction, + MockRole, + MockTextChannel, + MockUser, +) + + +class MockAsyncIterable: + """ + Helper for mocking asynchronous for loops. + + It does not appear that the `unittest` library currently provides anything that would + allow us to simply mock an async iterator, such as `discord.TextChannel.history`. + + We therefore write our own helper to wrap a regular synchronous iterable, and feed + its values via `__anext__` rather than `__next__`. + + This class was written for the purposes of testing the `Incidents` cog - it may not + be generic enough to be placed in the `tests.helpers` module. + """ + + def __init__(self, messages: t.Iterable): + """Take a sync iterable to be wrapped.""" + self.iter_messages = iter(messages) + + def __aiter__(self): + """Return `self` as we provide the `__anext__` method.""" + return self + + async def __anext__(self): + """ + Feed the next item, or raise `StopAsyncIteration`. + + Since we're wrapping a sync iterator, it will communicate that it has been depleted + by raising a `StopIteration`. The `async for` construct does not expect it, and we + therefore need to substitute it for the appropriate exception type. + """ + try: + return next(self.iter_messages) + except StopIteration: + raise StopAsyncIteration + + +class MockSignal(enum.Enum): + A = "A" + B = "B" + + +mock_404 = discord.NotFound( + response=MagicMock(aiohttp.ClientResponse), # Mock the erroneous response + message="Not found", +) + + +class TestDownloadFile(unittest.IsolatedAsyncioTestCase): + """Collection of tests for the `download_file` helper function.""" + + async def test_download_file_success(self): + """If `to_file` succeeds, function returns the acquired `discord.File`.""" + file = MagicMock(discord.File, filename="bigbadlemon.jpg") + attachment = MockAttachment(to_file=AsyncMock(return_value=file)) + + acquired_file = await incidents.download_file(attachment) + self.assertIs(file, acquired_file) + + async def test_download_file_404(self): + """If `to_file` encounters a 404, function handles the exception & returns None.""" + attachment = MockAttachment(to_file=AsyncMock(side_effect=mock_404)) + + acquired_file = await incidents.download_file(attachment) + self.assertIsNone(acquired_file) + + async def test_download_file_fail(self): + """If `to_file` fails on a non-404 error, function logs the exception & returns None.""" + arbitrary_error = discord.HTTPException(MagicMock(aiohttp.ClientResponse), "Arbitrary API error") + attachment = MockAttachment(to_file=AsyncMock(side_effect=arbitrary_error)) + + with self.assertLogs(logger=incidents.log, level=logging.ERROR): + acquired_file = await incidents.download_file(attachment) + + self.assertIsNone(acquired_file) + + +class TestMakeEmbed(unittest.IsolatedAsyncioTestCase): + """Collection of tests for the `make_embed` helper function.""" + + async def test_make_embed_actioned(self): + """Embed is coloured green and footer contains 'Actioned' when `outcome=Signal.ACTIONED`.""" + embed, file = await incidents.make_embed(MockMessage(), incidents.Signal.ACTIONED, MockMember()) + + self.assertEqual(embed.colour.value, Colours.soft_green) + self.assertIn("Actioned", embed.footer.text) + + async def test_make_embed_not_actioned(self): + """Embed is coloured red and footer contains 'Rejected' when `outcome=Signal.NOT_ACTIONED`.""" + embed, file = await incidents.make_embed(MockMessage(), incidents.Signal.NOT_ACTIONED, MockMember()) + + self.assertEqual(embed.colour.value, Colours.soft_red) + self.assertIn("Rejected", embed.footer.text) + + async def test_make_embed_content(self): + """Incident content appears as embed description.""" + incident = MockMessage(content="this is an incident") + embed, file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember()) + + self.assertEqual(incident.content, embed.description) + + async def test_make_embed_with_attachment_succeeds(self): + """Incident's attachment is downloaded and displayed in the embed's image field.""" + file = MagicMock(discord.File, filename="bigbadjoe.jpg") + attachment = MockAttachment(filename="bigbadjoe.jpg") + incident = MockMessage(content="this is an incident", attachments=[attachment]) + + # Patch `download_file` to return our `file` + with patch("bot.exts.moderation.incidents.download_file", AsyncMock(return_value=file)): + embed, returned_file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember()) + + self.assertIs(file, returned_file) + self.assertEqual("attachment://bigbadjoe.jpg", embed.image.url) + + async def test_make_embed_with_attachment_fails(self): + """Incident's attachment fails to download, proxy url is linked instead.""" + attachment = MockAttachment(proxy_url="discord.com/bigbadjoe.jpg") + incident = MockMessage(content="this is an incident", attachments=[attachment]) + + # Patch `download_file` to return None as if the download failed + with patch("bot.exts.moderation.incidents.download_file", AsyncMock(return_value=None)): + embed, returned_file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember()) + + self.assertIsNone(returned_file) + + # The author name field is simply expected to have something in it, we do not assert the message + self.assertGreater(len(embed.author.name), 0) + self.assertEqual(embed.author.url, "discord.com/bigbadjoe.jpg") # However, it should link the exact url + + +@patch("bot.constants.Channels.incidents", 123) +class TestIsIncident(unittest.TestCase): + """ + Collection of tests for the `is_incident` helper function. + + In `setUp`, we will create a mock message which should qualify as an incident. Each + test case will then mutate this instance to make it **not** qualify, in various ways. + + Notice that we patch the #incidents channel id globally for this class. + """ + + def setUp(self) -> None: + """Prepare a mock message which should qualify as an incident.""" + self.incident = MockMessage( + channel=MockTextChannel(id=123), + content="this is an incident", + author=MockUser(bot=False), + pinned=False, + ) + + def test_is_incident_true(self): + """Message qualifies as an incident if unchanged.""" + self.assertTrue(incidents.is_incident(self.incident)) + + def check_false(self): + """Assert that `self.incident` does **not** qualify as an incident.""" + self.assertFalse(incidents.is_incident(self.incident)) + + def test_is_incident_false_channel(self): + """Message doesn't qualify if sent outside of #incidents.""" + self.incident.channel = MockTextChannel(id=456) + self.check_false() + + def test_is_incident_false_content(self): + """Message doesn't qualify if content begins with hash symbol.""" + self.incident.content = "# this is a comment message" + self.check_false() + + def test_is_incident_false_author(self): + """Message doesn't qualify if author is a bot.""" + self.incident.author = MockUser(bot=True) + self.check_false() + + def test_is_incident_false_pinned(self): + """Message doesn't qualify if it is pinned.""" + self.incident.pinned = True + self.check_false() + + +class TestOwnReactions(unittest.TestCase): + """Assertions for the `own_reactions` function.""" + + def test_own_reactions(self): + """Only bot's own emoji are extracted from the input incident.""" + reactions = ( + MockReaction(emoji="A", me=True), + MockReaction(emoji="B", me=True), + MockReaction(emoji="C", me=False), + ) + message = MockMessage(reactions=reactions) + self.assertSetEqual(incidents.own_reactions(message), {"A", "B"}) + + +@patch("bot.exts.moderation.incidents.ALL_SIGNALS", {"A", "B"}) +class TestHasSignals(unittest.TestCase): + """ + Assertions for the `has_signals` function. + + We patch `ALL_SIGNALS` globally. Each test function then patches `own_reactions` + as appropriate. + """ + + def test_has_signals_true(self): + """True when `own_reactions` returns all emoji in `ALL_SIGNALS`.""" + message = MockMessage() + own_reactions = MagicMock(return_value={"A", "B"}) + + with patch("bot.exts.moderation.incidents.own_reactions", own_reactions): + self.assertTrue(incidents.has_signals(message)) + + def test_has_signals_false(self): + """False when `own_reactions` does not return all emoji in `ALL_SIGNALS`.""" + message = MockMessage() + own_reactions = MagicMock(return_value={"A", "C"}) + + with patch("bot.exts.moderation.incidents.own_reactions", own_reactions): + self.assertFalse(incidents.has_signals(message)) + + +@patch("bot.exts.moderation.incidents.Signal", MockSignal) +class TestAddSignals(unittest.IsolatedAsyncioTestCase): + """ + Assertions for the `add_signals` coroutine. + + These are all fairly similar and could go into a single test function, but I found the + patching & sub-testing fairly awkward in that case and decided to split them up + to avoid unnecessary syntax noise. + """ + + def setUp(self): + """Prepare a mock incident message for tests to use.""" + self.incident = MockMessage() + + @patch("bot.exts.moderation.incidents.own_reactions", MagicMock(return_value=set())) + async def test_add_signals_missing(self): + """All emoji are added when none are present.""" + await incidents.add_signals(self.incident) + self.incident.add_reaction.assert_has_calls([call("A"), call("B")]) + + @patch("bot.exts.moderation.incidents.own_reactions", MagicMock(return_value={"A"})) + async def test_add_signals_partial(self): + """Only missing emoji are added when some are present.""" + await incidents.add_signals(self.incident) + self.incident.add_reaction.assert_has_calls([call("B")]) + + @patch("bot.exts.moderation.incidents.own_reactions", MagicMock(return_value={"A", "B"})) + async def test_add_signals_present(self): + """No emoji are added when all are present.""" + await incidents.add_signals(self.incident) + self.incident.add_reaction.assert_not_called() + + +class TestIncidents(unittest.IsolatedAsyncioTestCase): + """ + Tests for bound methods of the `Incidents` cog. + + Use this as a base class for `Incidents` tests - it will prepare a fresh instance + for each test function, but not make any assertions on its own. Tests can mutate + the instance as they wish. + """ + + def setUp(self): + """ + Prepare a fresh `Incidents` instance for each test. + + Note that this will not schedule `crawl_incidents` in the background, as everything + is being mocked. The `crawl_task` attribute will end up being None. + """ + self.cog_instance = incidents.Incidents(MockBot()) + + +@patch("asyncio.sleep", AsyncMock()) # Prevent the coro from sleeping to speed up the test +class TestCrawlIncidents(TestIncidents): + """ + Tests for the `Incidents.crawl_incidents` coroutine. + + Apart from `test_crawl_incidents_waits_until_cache_ready`, all tests in this class + will patch the return values of `is_incident` and `has_signal` and then observe + whether the `AsyncMock` for `add_signals` was awaited or not. + + The `add_signals` mock is added by each test separately to ensure it is clean (has not + been awaited by another test yet). The mock can be reset, but this appears to be the + cleaner way. + + For each test, we inject a mock channel with a history of 1 message only (see: `setUp`). + """ + + def setUp(self): + """For each test, ensure `bot.get_channel` returns a channel with 1 arbitrary message.""" + super().setUp() # First ensure we get `cog_instance` from parent + + incidents_history = MagicMock(return_value=MockAsyncIterable([MockMessage()])) + self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(history=incidents_history)) + + async def test_crawl_incidents_waits_until_cache_ready(self): + """ + The coroutine will await the `wait_until_guild_available` event. + + Since this task is schedule in the `__init__`, it is critical that it waits for the + cache to be ready, so that it can safely get the #incidents channel. + """ + await self.cog_instance.crawl_incidents() + self.cog_instance.bot.wait_until_guild_available.assert_awaited() + + @patch("bot.exts.moderation.incidents.add_signals", AsyncMock()) + @patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=False)) # Message doesn't qualify + @patch("bot.exts.moderation.incidents.has_signals", MagicMock(return_value=False)) + async def test_crawl_incidents_noop_if_is_not_incident(self): + """Signals are not added for a non-incident message.""" + await self.cog_instance.crawl_incidents() + incidents.add_signals.assert_not_awaited() + + @patch("bot.exts.moderation.incidents.add_signals", AsyncMock()) + @patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=True)) # Message qualifies + @patch("bot.exts.moderation.incidents.has_signals", MagicMock(return_value=True)) # But already has signals + async def test_crawl_incidents_noop_if_message_already_has_signals(self): + """Signals are not added for messages which already have them.""" + await self.cog_instance.crawl_incidents() + incidents.add_signals.assert_not_awaited() + + @patch("bot.exts.moderation.incidents.add_signals", AsyncMock()) + @patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=True)) # Message qualifies + @patch("bot.exts.moderation.incidents.has_signals", MagicMock(return_value=False)) # And doesn't have signals + async def test_crawl_incidents_add_signals_called(self): + """Message has signals added as it does not have them yet and qualifies as an incident.""" + await self.cog_instance.crawl_incidents() + incidents.add_signals.assert_awaited_once() + + +class TestArchive(TestIncidents): + """Tests for the `Incidents.archive` coroutine.""" + + async def test_archive_webhook_not_found(self): + """ + Method recovers and returns False when the webhook is not found. + + Implicitly, this also tests that the error is handled internally and doesn't + propagate out of the method, which is just as important. + """ + self.cog_instance.bot.fetch_webhook = AsyncMock(side_effect=mock_404) + self.assertFalse( + await self.cog_instance.archive(incident=MockMessage(), outcome=MagicMock(), actioned_by=MockMember()) + ) + + async def test_archive_relays_incident(self): + """ + If webhook is found, method relays `incident` properly. + + This test will assert that the fetched webhook's `send` method is fed the correct arguments, + and that the `archive` method returns True. + """ + webhook = MockAsyncWebhook() + self.cog_instance.bot.fetch_webhook = AsyncMock(return_value=webhook) # Patch in our webhook + + # Define our own `incident` to be archived + incident = MockMessage( + content="this is an incident", + author=MockUser(name="author_name", avatar_url="author_avatar"), + id=123, + ) + built_embed = MagicMock(discord.Embed, id=123) # We patch `make_embed` to return this + + with patch("bot.exts.moderation.incidents.make_embed", AsyncMock(return_value=(built_embed, None))): + archive_return = await self.cog_instance.archive(incident, MagicMock(value="A"), MockMember()) + + # Now we check that the webhook was given the correct args, and that `archive` returned True + webhook.send.assert_called_once_with( + embed=built_embed, + username="author_name", + avatar_url="author_avatar", + file=None, + ) + self.assertTrue(archive_return) + + async def test_archive_clyde_username(self): + """ + The archive webhook username is cleansed using `sub_clyde`. + + Discord will reject any webhook with "clyde" in the username field, as it impersonates + the official Clyde bot. Since we do not control what the username will be (the incident + author name is used), we must ensure the name is cleansed, otherwise the relay may fail. + + This test assumes the username is passed as a kwarg. If this test fails, please review + whether the passed argument is being retrieved correctly. + """ + webhook = MockAsyncWebhook() + self.cog_instance.bot.fetch_webhook = AsyncMock(return_value=webhook) + + message_from_clyde = MockMessage(author=MockUser(name="clyde the great")) + await self.cog_instance.archive(message_from_clyde, MagicMock(incidents.Signal), MockMember()) + + self.assertNotIn("clyde", webhook.send.call_args.kwargs["username"]) + + +class TestMakeConfirmationTask(TestIncidents): + """ + Tests for the `Incidents.make_confirmation_task` method. + + Writing tests for this method is difficult, as it mostly just delegates the provided + information elsewhere. There is very little internal logic. Whether our approach + works conceptually is difficult to prove using unit tests. + """ + + def test_make_confirmation_task_check(self): + """ + The internal check will recognize the passed incident. + + This is a little tricky - we first pass a message with a specific `id` in, and then + retrieve the built check from the `call_args` of the `wait_for` method. This relies + on the check being passed as a kwarg. + + Once the check is retrieved, we assert that it gives True for our incident's `id`, + and False for any other. + + If this function begins to fail, first check that `created_check` is being retrieved + correctly. It should be the function that is built locally in the tested method. + """ + self.cog_instance.make_confirmation_task(MockMessage(id=123)) + + self.cog_instance.bot.wait_for.assert_called_once() + created_check = self.cog_instance.bot.wait_for.call_args.kwargs["check"] + + # The `message_id` matches the `id` of our incident + self.assertTrue(created_check(payload=MagicMock(message_id=123))) + + # This `message_id` does not match + self.assertFalse(created_check(payload=MagicMock(message_id=0))) + + +@patch("bot.exts.moderation.incidents.ALLOWED_ROLES", {1, 2}) +@patch("bot.exts.moderation.incidents.Incidents.make_confirmation_task", AsyncMock()) # Generic awaitable +class TestProcessEvent(TestIncidents): + """Tests for the `Incidents.process_event` coroutine.""" + + async def test_process_event_bad_role(self): + """The reaction is removed when the author lacks all allowed roles.""" + incident = MockMessage() + member = MockMember(roles=[MockRole(id=0)]) # Must have role 1 or 2 + + await self.cog_instance.process_event("reaction", incident, member) + incident.remove_reaction.assert_called_once_with("reaction", member) + + async def test_process_event_bad_emoji(self): + """ + The reaction is removed when an invalid emoji is used. + + This requires that we pass in a `member` with valid roles, as we need the role check + to succeed. + """ + incident = MockMessage() + member = MockMember(roles=[MockRole(id=1)]) # Member has allowed role + + await self.cog_instance.process_event("invalid_signal", incident, member) + incident.remove_reaction.assert_called_once_with("invalid_signal", member) + + async def test_process_event_no_archive_on_investigating(self): + """Message is not archived on `Signal.INVESTIGATING`.""" + with patch("bot.exts.moderation.incidents.Incidents.archive", AsyncMock()) as mocked_archive: + await self.cog_instance.process_event( + reaction=incidents.Signal.INVESTIGATING.value, + incident=MockMessage(), + member=MockMember(roles=[MockRole(id=1)]), + ) + + mocked_archive.assert_not_called() + + async def test_process_event_no_delete_if_archive_fails(self): + """ + Original message is not deleted when `Incidents.archive` returns False. + + This is the way of signaling that the relay failed, and we should not remove the original, + as that would result in losing the incident record. + """ + incident = MockMessage() + + with patch("bot.exts.moderation.incidents.Incidents.archive", AsyncMock(return_value=False)): + await self.cog_instance.process_event( + reaction=incidents.Signal.ACTIONED.value, + incident=incident, + member=MockMember(roles=[MockRole(id=1)]) + ) + + incident.delete.assert_not_called() + + async def test_process_event_confirmation_task_is_awaited(self): + """Task given by `Incidents.make_confirmation_task` is awaited before method exits.""" + mock_task = AsyncMock() + + with patch("bot.exts.moderation.incidents.Incidents.make_confirmation_task", mock_task): + await self.cog_instance.process_event( + reaction=incidents.Signal.ACTIONED.value, + incident=MockMessage(), + member=MockMember(roles=[MockRole(id=1)]) + ) + + mock_task.assert_awaited() + + async def test_process_event_confirmation_task_timeout_is_handled(self): + """ + Confirmation task `asyncio.TimeoutError` is handled gracefully. + + We have `make_confirmation_task` return a mock with a side effect, and then catch the + exception should it propagate out of `process_event`. This is so that we can then manually + fail the test with a more informative message than just the plain traceback. + """ + mock_task = AsyncMock(side_effect=asyncio.TimeoutError()) + + try: + with patch("bot.exts.moderation.incidents.Incidents.make_confirmation_task", mock_task): + await self.cog_instance.process_event( + reaction=incidents.Signal.ACTIONED.value, + incident=MockMessage(), + member=MockMember(roles=[MockRole(id=1)]) + ) + except asyncio.TimeoutError: + self.fail("TimeoutError was not handled gracefully, and propagated out of `process_event`!") + + +class TestResolveMessage(TestIncidents): + """Tests for the `Incidents.resolve_message` coroutine.""" + + async def test_resolve_message_pass_message_id(self): + """Method will call `_get_message` with the passed `message_id`.""" + await self.cog_instance.resolve_message(123) + self.cog_instance.bot._connection._get_message.assert_called_once_with(123) + + async def test_resolve_message_in_cache(self): + """ + No API call is made if the queried message exists in the cache. + + We mock the `_get_message` return value regardless of input. Whether it finds the message + internally is considered d.py's responsibility, not ours. + """ + cached_message = MockMessage(id=123) + self.cog_instance.bot._connection._get_message = MagicMock(return_value=cached_message) + + return_value = await self.cog_instance.resolve_message(123) + + self.assertIs(return_value, cached_message) + self.cog_instance.bot.get_channel.assert_not_called() # The `fetch_message` line was never hit + + async def test_resolve_message_not_in_cache(self): + """ + The message is retrieved from the API if it isn't cached. + + This is desired behaviour for messages which exist, but were sent before the bot's + current session. + """ + self.cog_instance.bot._connection._get_message = MagicMock(return_value=None) # Cache returns None + + # API returns our message + uncached_message = MockMessage() + fetch_message = AsyncMock(return_value=uncached_message) + self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(fetch_message=fetch_message)) + + retrieved_message = await self.cog_instance.resolve_message(123) + self.assertIs(retrieved_message, uncached_message) + + async def test_resolve_message_doesnt_exist(self): + """ + If the API returns a 404, the function handles it gracefully and returns None. + + This is an edge-case happening with racing events - event A will relay the message + to the archive and delete the original. Once event B acquires the `event_lock`, + it will not find the message in the cache, and will ask the API. + """ + self.cog_instance.bot._connection._get_message = MagicMock(return_value=None) # Cache returns None + + fetch_message = AsyncMock(side_effect=mock_404) + self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(fetch_message=fetch_message)) + + self.assertIsNone(await self.cog_instance.resolve_message(123)) + + async def test_resolve_message_fetch_fails(self): + """ + Non-404 errors are handled, logged & None is returned. + + In contrast with a 404, this should make an error-level log. We assert that at least + one such log was made - we do not make any assertions about the log's message. + """ + self.cog_instance.bot._connection._get_message = MagicMock(return_value=None) # Cache returns None + + arbitrary_error = discord.HTTPException( + response=MagicMock(aiohttp.ClientResponse), + message="Arbitrary error", + ) + fetch_message = AsyncMock(side_effect=arbitrary_error) + self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(fetch_message=fetch_message)) + + with self.assertLogs(logger=incidents.log, level=logging.ERROR): + self.assertIsNone(await self.cog_instance.resolve_message(123)) + + +@patch("bot.constants.Channels.incidents", 123) +class TestOnRawReactionAdd(TestIncidents): + """ + Tests for the `Incidents.on_raw_reaction_add` listener. + + Writing tests for this listener comes with additional complexity due to the listener + awaiting the `crawl_task` task. See `asyncSetUp` for further details, which attempts + to make unit testing this function possible. + """ + + def setUp(self): + """ + Prepare & assign `payload` attribute. + + This attribute represents an *ideal* payload which will not be rejected by the + listener. As each test will receive a fresh instance, it can be mutated to + observe how the listener's behaviour changes with different attributes on + the passed payload. + """ + super().setUp() # Ensure `cog_instance` is assigned + + self.payload = MagicMock( + discord.RawReactionActionEvent, + channel_id=123, # Patched at class level + message_id=456, + member=MockMember(bot=False), + emoji="reaction", + ) + + async def asyncSetUp(self): # noqa: N802 + """ + Prepare an empty task and assign it as `crawl_task`. + + It appears that the `unittest` framework does not provide anything for mocking + asyncio tasks. An `AsyncMock` instance can be called and then awaited, however, + it does not provide the `done` method or any other parts of the `asyncio.Task` + interface. + + Although we do not need to make any assertions about the task itself while + testing the listener, the code will still await it and call the `done` method, + and so we must inject something that will not fail on either action. + + Note that this is done in an `asyncSetUp`, which runs after `setUp`. + The justification is that creating an actual task requires the event + loop to be ready, which is not the case in the `setUp`. + """ + mock_task = asyncio.create_task(AsyncMock()()) # Mock async func, then a coro + self.cog_instance.crawl_task = mock_task + + async def test_on_raw_reaction_add_wrong_channel(self): + """ + Events outside of #incidents will be ignored. + + We check this by asserting that `resolve_message` was never queried. + """ + self.payload.channel_id = 0 + self.cog_instance.resolve_message = AsyncMock() + + await self.cog_instance.on_raw_reaction_add(self.payload) + self.cog_instance.resolve_message.assert_not_called() + + async def test_on_raw_reaction_add_user_is_bot(self): + """ + Events dispatched by bot accounts will be ignored. + + We check this by asserting that `resolve_message` was never queried. + """ + self.payload.member = MockMember(bot=True) + self.cog_instance.resolve_message = AsyncMock() + + await self.cog_instance.on_raw_reaction_add(self.payload) + self.cog_instance.resolve_message.assert_not_called() + + async def test_on_raw_reaction_add_message_doesnt_exist(self): + """ + Listener gracefully handles the case where `resolve_message` gives None. + + We check this by asserting that `process_event` was never called. + """ + self.cog_instance.process_event = AsyncMock() + self.cog_instance.resolve_message = AsyncMock(return_value=None) + + await self.cog_instance.on_raw_reaction_add(self.payload) + self.cog_instance.process_event.assert_not_called() + + async def test_on_raw_reaction_add_message_is_not_an_incident(self): + """ + The event won't be processed if the related message is not an incident. + + This is an edge-case that can happen if someone manually leaves a reaction + on a pinned message, or a comment. + + We check this by asserting that `process_event` was never called. + """ + self.cog_instance.process_event = AsyncMock() + self.cog_instance.resolve_message = AsyncMock(return_value=MockMessage()) + + with patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=False)): + await self.cog_instance.on_raw_reaction_add(self.payload) + + self.cog_instance.process_event.assert_not_called() + + async def test_on_raw_reaction_add_valid_event_is_processed(self): + """ + If the reaction event is valid, it is passed to `process_event`. + + This is the case when everything goes right: + * The reaction was placed in #incidents, and not by a bot + * The message was found successfully + * The message qualifies as an incident + + Additionally, we check that all arguments were passed as expected. + """ + incident = MockMessage(id=1) + + self.cog_instance.process_event = AsyncMock() + self.cog_instance.resolve_message = AsyncMock(return_value=incident) + + with patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=True)): + await self.cog_instance.on_raw_reaction_add(self.payload) + + self.cog_instance.process_event.assert_called_with( + "reaction", # Defined in `self.payload` + incident, + self.payload.member, + ) + + +class TestOnMessage(TestIncidents): + """ + Tests for the `Incidents.on_message` listener. + + Notice the decorators mocking the `is_incident` return value. The `is_incidents` + function is tested in `TestIsIncident` - here we do not worry about it. + """ + + @patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=True)) + async def test_on_message_incident(self): + """Messages qualifying as incidents are passed to `add_signals`.""" + incident = MockMessage() + + with patch("bot.exts.moderation.incidents.add_signals", AsyncMock()) as mock_add_signals: + await self.cog_instance.on_message(incident) + + mock_add_signals.assert_called_once_with(incident) + + @patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=False)) + async def test_on_message_non_incident(self): + """Messages not qualifying as incidents are ignored.""" + with patch("bot.exts.moderation.incidents.add_signals", AsyncMock()) as mock_add_signals: + await self.cog_instance.on_message(MockMessage()) + + mock_add_signals.assert_not_called() diff --git a/tests/bot/exts/moderation/test_modlog.py b/tests/bot/exts/moderation/test_modlog.py new file mode 100644 index 000000000..f8f142484 --- /dev/null +++ b/tests/bot/exts/moderation/test_modlog.py @@ -0,0 +1,29 @@ +import unittest + +import discord + +from bot.exts.moderation.modlog import ModLog +from tests.helpers import MockBot, MockTextChannel + + +class ModLogTests(unittest.IsolatedAsyncioTestCase): + """Tests for moderation logs.""" + + def setUp(self): + self.bot = MockBot() + self.cog = ModLog(self.bot) + self.channel = MockTextChannel() + + async def test_log_entry_description_truncation(self): + """Test that embed description for ModLog entry is truncated.""" + self.bot.get_channel.return_value = self.channel + await self.cog.send_log_message( + icon_url="foo", + colour=discord.Colour.blue(), + title="bar", + text="foo bar" * 3000 + ) + embed = self.channel.send.call_args[1]["embed"] + self.assertEqual( + embed.description, ("foo bar" * 3000)[:2045] + "..." + ) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py new file mode 100644 index 000000000..8c4fb764a --- /dev/null +++ b/tests/bot/exts/moderation/test_silence.py @@ -0,0 +1,261 @@ +import unittest +from unittest import mock +from unittest.mock import MagicMock, Mock + +from discord import PermissionOverwrite + +from bot.constants import Channels, Emojis, Guild, Roles +from bot.exts.moderation.silence import Silence, SilenceNotifier +from tests.helpers import MockBot, MockContext, MockTextChannel + + +class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.alert_channel = MockTextChannel() + self.notifier = SilenceNotifier(self.alert_channel) + self.notifier.stop = self.notifier_stop_mock = Mock() + self.notifier.start = self.notifier_start_mock = Mock() + + def test_add_channel_adds_channel(self): + """Channel in FirstHash with current loop is added to internal set.""" + channel = Mock() + with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels: + self.notifier.add_channel(channel) + silenced_channels.__setitem__.assert_called_with(channel, self.notifier._current_loop) + + def test_add_channel_starts_loop(self): + """Loop is started if `_silenced_channels` was empty.""" + self.notifier.add_channel(Mock()) + self.notifier_start_mock.assert_called_once() + + def test_add_channel_skips_start_with_channels(self): + """Loop start is not called when `_silenced_channels` is not empty.""" + with mock.patch.object(self.notifier, "_silenced_channels"): + self.notifier.add_channel(Mock()) + self.notifier_start_mock.assert_not_called() + + def test_remove_channel_removes_channel(self): + """Channel in FirstHash is removed from `_silenced_channels`.""" + channel = Mock() + with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels: + self.notifier.remove_channel(channel) + silenced_channels.__delitem__.assert_called_with(channel) + + def test_remove_channel_stops_loop(self): + """Notifier loop is stopped if `_silenced_channels` is empty after remove.""" + with mock.patch.object(self.notifier, "_silenced_channels", __bool__=lambda _: False): + self.notifier.remove_channel(Mock()) + self.notifier_stop_mock.assert_called_once() + + def test_remove_channel_skips_stop_with_channels(self): + """Notifier loop is not stopped if `_silenced_channels` is not empty after remove.""" + self.notifier.remove_channel(Mock()) + self.notifier_stop_mock.assert_not_called() + + async def test_notifier_private_sends_alert(self): + """Alert is sent on 15 min intervals.""" + test_cases = (900, 1800, 2700) + for current_loop in test_cases: + with self.subTest(current_loop=current_loop): + with mock.patch.object(self.notifier, "_current_loop", new=current_loop): + await self.notifier._notifier() + self.alert_channel.send.assert_called_once_with(f"<@&{Roles.moderators}> currently silenced channels: ") + self.alert_channel.send.reset_mock() + + async def test_notifier_skips_alert(self): + """Alert is skipped on first loop or not an increment of 900.""" + test_cases = (0, 15, 5000) + for current_loop in test_cases: + with self.subTest(current_loop=current_loop): + with mock.patch.object(self.notifier, "_current_loop", new=current_loop): + await self.notifier._notifier() + self.alert_channel.send.assert_not_called() + + +class SilenceTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.bot = MockBot() + self.cog = Silence(self.bot) + self.ctx = MockContext() + self.cog._verified_role = None + # Set event so command callbacks can continue. + self.cog._get_instance_vars_event.set() + + async def test_instance_vars_got_guild(self): + """Bot got guild after it became available.""" + await self.cog._get_instance_vars() + self.bot.wait_until_guild_available.assert_called_once() + self.bot.get_guild.assert_called_once_with(Guild.id) + + async def test_instance_vars_got_role(self): + """Got `Roles.verified` role from guild.""" + await self.cog._get_instance_vars() + guild = self.bot.get_guild() + guild.get_role.assert_called_once_with(Roles.verified) + + async def test_instance_vars_got_channels(self): + """Got channels from bot.""" + await self.cog._get_instance_vars() + self.bot.get_channel.called_once_with(Channels.mod_alerts) + self.bot.get_channel.called_once_with(Channels.mod_log) + + @mock.patch("bot.exts.moderation.silence.SilenceNotifier") + async def test_instance_vars_got_notifier(self, notifier): + """Notifier was started with channel.""" + mod_log = MockTextChannel() + self.bot.get_channel.side_effect = (None, mod_log) + await self.cog._get_instance_vars() + notifier.assert_called_once_with(mod_log) + self.bot.get_channel.side_effect = None + + async def test_silence_sent_correct_discord_message(self): + """Check if proper message was sent when called with duration in channel with previous state.""" + test_cases = ( + (0.0001, f"{Emojis.check_mark} silenced current channel for 0.0001 minute(s).", True,), + (None, f"{Emojis.check_mark} silenced current channel indefinitely.", True,), + (5, f"{Emojis.cross_mark} current channel is already silenced.", False,), + ) + for duration, result_message, _silence_patch_return in test_cases: + with self.subTest( + silence_duration=duration, + result_message=result_message, + starting_unsilenced_state=_silence_patch_return + ): + with mock.patch.object(self.cog, "_silence", return_value=_silence_patch_return): + await self.cog.silence.callback(self.cog, self.ctx, duration) + self.ctx.send.assert_called_once_with(result_message) + self.ctx.reset_mock() + + async def test_unsilence_sent_correct_discord_message(self): + """Check if proper message was sent when unsilencing channel.""" + test_cases = ( + (True, f"{Emojis.check_mark} unsilenced current channel."), + (False, f"{Emojis.cross_mark} current channel was not silenced.") + ) + for _unsilence_patch_return, result_message in test_cases: + with self.subTest( + starting_silenced_state=_unsilence_patch_return, + result_message=result_message + ): + with mock.patch.object(self.cog, "_unsilence", return_value=_unsilence_patch_return): + await self.cog.unsilence.callback(self.cog, self.ctx) + self.ctx.send.assert_called_once_with(result_message) + self.ctx.reset_mock() + + async def test_silence_private_for_false(self): + """Permissions are not set and `False` is returned in an already silenced channel.""" + perm_overwrite = Mock(send_messages=False) + channel = Mock(overwrites_for=Mock(return_value=perm_overwrite)) + + self.assertFalse(await self.cog._silence(channel, True, None)) + channel.set_permissions.assert_not_called() + + async def test_silence_private_silenced_channel(self): + """Channel had `send_message` permissions revoked.""" + channel = MockTextChannel() + self.assertTrue(await self.cog._silence(channel, False, None)) + channel.set_permissions.assert_called_once() + self.assertFalse(channel.set_permissions.call_args.kwargs['send_messages']) + + async def test_silence_private_preserves_permissions(self): + """Previous permissions were preserved when channel was silenced.""" + channel = MockTextChannel() + # Set up mock channel permission state. + mock_permissions = PermissionOverwrite() + mock_permissions_dict = dict(mock_permissions) + channel.overwrites_for.return_value = mock_permissions + await self.cog._silence(channel, False, None) + new_permissions = channel.set_permissions.call_args.kwargs + # Remove 'send_messages' key because it got changed in the method. + del new_permissions['send_messages'] + del mock_permissions_dict['send_messages'] + self.assertDictEqual(mock_permissions_dict, new_permissions) + + async def test_silence_private_notifier(self): + """Channel should be added to notifier with `persistent` set to `True`, and the other way around.""" + channel = MockTextChannel() + with mock.patch.object(self.cog, "notifier", create=True): + with self.subTest(persistent=True): + await self.cog._silence(channel, True, None) + self.cog.notifier.add_channel.assert_called_once() + + with mock.patch.object(self.cog, "notifier", create=True): + with self.subTest(persistent=False): + await self.cog._silence(channel, False, None) + self.cog.notifier.add_channel.assert_not_called() + + async def test_silence_private_added_muted_channel(self): + """Channel was added to `muted_channels` on silence.""" + channel = MockTextChannel() + with mock.patch.object(self.cog, "muted_channels") as muted_channels: + await self.cog._silence(channel, False, None) + muted_channels.add.assert_called_once_with(channel) + + async def test_unsilence_private_for_false(self): + """Permissions are not set and `False` is returned in an unsilenced channel.""" + channel = Mock() + self.assertFalse(await self.cog._unsilence(channel)) + channel.set_permissions.assert_not_called() + + @mock.patch.object(Silence, "notifier", create=True) + async def test_unsilence_private_unsilenced_channel(self, _): + """Channel had `send_message` permissions restored""" + perm_overwrite = MagicMock(send_messages=False) + channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) + self.assertTrue(await self.cog._unsilence(channel)) + channel.set_permissions.assert_called_once() + self.assertIsNone(channel.set_permissions.call_args.kwargs['send_messages']) + + @mock.patch.object(Silence, "notifier", create=True) + async def test_unsilence_private_removed_notifier(self, notifier): + """Channel was removed from `notifier` on unsilence.""" + perm_overwrite = MagicMock(send_messages=False) + channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) + await self.cog._unsilence(channel) + notifier.remove_channel.assert_called_once_with(channel) + + @mock.patch.object(Silence, "notifier", create=True) + async def test_unsilence_private_removed_muted_channel(self, _): + """Channel was removed from `muted_channels` on unsilence.""" + perm_overwrite = MagicMock(send_messages=False) + channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) + with mock.patch.object(self.cog, "muted_channels") as muted_channels: + await self.cog._unsilence(channel) + muted_channels.discard.assert_called_once_with(channel) + + @mock.patch.object(Silence, "notifier", create=True) + async def test_unsilence_private_preserves_permissions(self, _): + """Previous permissions were preserved when channel was unsilenced.""" + channel = MockTextChannel() + # Set up mock channel permission state. + mock_permissions = PermissionOverwrite(send_messages=False) + mock_permissions_dict = dict(mock_permissions) + channel.overwrites_for.return_value = mock_permissions + await self.cog._unsilence(channel) + new_permissions = channel.set_permissions.call_args.kwargs + # Remove 'send_messages' key because it got changed in the method. + del new_permissions['send_messages'] + del mock_permissions_dict['send_messages'] + self.assertDictEqual(mock_permissions_dict, new_permissions) + + @mock.patch("bot.exts.moderation.silence.asyncio") + @mock.patch.object(Silence, "_mod_alerts_channel", create=True) + def test_cog_unload_starts_task(self, alert_channel, asyncio_mock): + """Task for sending an alert was created with present `muted_channels`.""" + with mock.patch.object(self.cog, "muted_channels"): + self.cog.cog_unload() + alert_channel.send.assert_called_once_with(f"<@&{Roles.moderators}> channels left silenced on cog unload: ") + asyncio_mock.create_task.assert_called_once_with(alert_channel.send()) + + @mock.patch("bot.exts.moderation.silence.asyncio") + def test_cog_unload_skips_task_start(self, asyncio_mock): + """No task created with no channels.""" + self.cog.cog_unload() + asyncio_mock.create_task.assert_not_called() + + @mock.patch("bot.exts.moderation.silence.with_role_check") + @mock.patch("bot.exts.moderation.silence.MODERATION_ROLES", new=(1, 2, 3)) + def test_cog_check(self, role_check): + """Role check is called with `MODERATION_ROLES`""" + self.cog.cog_check(self.ctx) + role_check.assert_called_once_with(self.ctx, *(1, 2, 3)) diff --git a/tests/bot/exts/moderation/test_slowmode.py b/tests/bot/exts/moderation/test_slowmode.py new file mode 100644 index 000000000..e90394ab9 --- /dev/null +++ b/tests/bot/exts/moderation/test_slowmode.py @@ -0,0 +1,111 @@ +import unittest +from unittest import mock + +from dateutil.relativedelta import relativedelta + +from bot.constants import Emojis +from bot.exts.moderation.slowmode import Slowmode +from tests.helpers import MockBot, MockContext, MockTextChannel + + +class SlowmodeTests(unittest.IsolatedAsyncioTestCase): + + def setUp(self) -> None: + self.bot = MockBot() + self.cog = Slowmode(self.bot) + self.ctx = MockContext() + + async def test_get_slowmode_no_channel(self) -> None: + """Get slowmode without a given channel.""" + self.ctx.channel = MockTextChannel(name='python-general', slowmode_delay=5) + + await self.cog.get_slowmode(self.cog, self.ctx, None) + self.ctx.send.assert_called_once_with("The slowmode delay for #python-general is 5 seconds.") + + async def test_get_slowmode_with_channel(self) -> None: + """Get slowmode with a given channel.""" + text_channel = MockTextChannel(name='python-language', slowmode_delay=2) + + await self.cog.get_slowmode(self.cog, self.ctx, text_channel) + self.ctx.send.assert_called_once_with('The slowmode delay for #python-language is 2 seconds.') + + async def test_set_slowmode_no_channel(self) -> None: + """Set slowmode without a given channel.""" + test_cases = ( + ('helpers', 23, True, f'{Emojis.check_mark} The slowmode delay for #helpers is now 23 seconds.'), + ('mods', 76526, False, f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.'), + ('admins', 97, True, f'{Emojis.check_mark} The slowmode delay for #admins is now 1 minute and 37 seconds.') + ) + + for channel_name, seconds, edited, result_msg in test_cases: + with self.subTest( + channel_mention=channel_name, + seconds=seconds, + edited=edited, + result_msg=result_msg + ): + self.ctx.channel = MockTextChannel(name=channel_name) + + await self.cog.set_slowmode(self.cog, self.ctx, None, relativedelta(seconds=seconds)) + + if edited: + self.ctx.channel.edit.assert_awaited_once_with(slowmode_delay=float(seconds)) + else: + self.ctx.channel.edit.assert_not_called() + + self.ctx.send.assert_called_once_with(result_msg) + + self.ctx.reset_mock() + + async def test_set_slowmode_with_channel(self) -> None: + """Set slowmode with a given channel.""" + test_cases = ( + ('bot-commands', 12, True, f'{Emojis.check_mark} The slowmode delay for #bot-commands is now 12 seconds.'), + ('mod-spam', 21, True, f'{Emojis.check_mark} The slowmode delay for #mod-spam is now 21 seconds.'), + ('admin-spam', 4323598, False, f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.') + ) + + for channel_name, seconds, edited, result_msg in test_cases: + with self.subTest( + channel_mention=channel_name, + seconds=seconds, + edited=edited, + result_msg=result_msg + ): + text_channel = MockTextChannel(name=channel_name) + + await self.cog.set_slowmode(self.cog, self.ctx, text_channel, relativedelta(seconds=seconds)) + + if edited: + text_channel.edit.assert_awaited_once_with(slowmode_delay=float(seconds)) + else: + text_channel.edit.assert_not_called() + + self.ctx.send.assert_called_once_with(result_msg) + + self.ctx.reset_mock() + + async def test_reset_slowmode_no_channel(self) -> None: + """Reset slowmode without a given channel.""" + self.ctx.channel = MockTextChannel(name='careers', slowmode_delay=6) + + await self.cog.reset_slowmode(self.cog, self.ctx, None) + self.ctx.send.assert_called_once_with( + f'{Emojis.check_mark} The slowmode delay for #careers has been reset to 0 seconds.' + ) + + async def test_reset_slowmode_with_channel(self) -> None: + """Reset slowmode with a given channel.""" + text_channel = MockTextChannel(name='meta', slowmode_delay=1) + + await self.cog.reset_slowmode(self.cog, self.ctx, text_channel) + self.ctx.send.assert_called_once_with( + f'{Emojis.check_mark} The slowmode delay for #meta has been reset to 0 seconds.' + ) + + @mock.patch("bot.exts.moderation.slowmode.with_role_check") + @mock.patch("bot.exts.moderation.slowmode.MODERATION_ROLES", new=(1, 2, 3)) + def test_cog_check(self, role_check): + """Role check is called with `MODERATION_ROLES`""" + self.cog.cog_check(self.ctx) + role_check.assert_called_once_with(self.ctx, *(1, 2, 3)) diff --git a/tests/bot/exts/test_cogs.py b/tests/bot/exts/test_cogs.py new file mode 100644 index 000000000..775c40722 --- /dev/null +++ b/tests/bot/exts/test_cogs.py @@ -0,0 +1,81 @@ +"""Test suite for general tests which apply to all cogs.""" + +import importlib +import pkgutil +import typing as t +import unittest +from collections import defaultdict +from types import ModuleType +from unittest import mock + +from discord.ext import commands + +from bot import exts + + +class CommandNameTests(unittest.TestCase): + """Tests for shadowing command names and aliases.""" + + @staticmethod + def walk_commands(cog: commands.Cog) -> t.Iterator[commands.Command]: + """An iterator that recursively walks through `cog`'s commands and subcommands.""" + # Can't use Bot.walk_commands() or Cog.get_commands() cause those are instance methods. + for command in cog.__cog_commands__: + if command.parent is None: + yield command + if isinstance(command, commands.GroupMixin): + # Annoyingly it returns duplicates for each alias so use a set to fix that + yield from set(command.walk_commands()) + + @staticmethod + def walk_modules() -> t.Iterator[ModuleType]: + """Yield imported modules from the bot.exts subpackage.""" + def on_error(name: str) -> t.NoReturn: + raise ImportError(name=name) # pragma: no cover + + # The mock prevents asyncio.get_event_loop() from being called. + with mock.patch("discord.ext.tasks.loop"): + prefix = f"{exts.__name__}." + for module in pkgutil.walk_packages(exts.__path__, prefix, onerror=on_error): + if not module.ispkg: + yield importlib.import_module(module.name) + + @staticmethod + def walk_cogs(module: ModuleType) -> t.Iterator[commands.Cog]: + """Yield all cogs defined in an extension.""" + for obj in module.__dict__.values(): + # Check if it's a class type cause otherwise issubclass() may raise a TypeError. + is_cog = isinstance(obj, type) and issubclass(obj, commands.Cog) + if is_cog and obj.__module__ == module.__name__: + yield obj + + @staticmethod + def get_qualified_names(command: commands.Command) -> t.List[str]: + """Return a list of all qualified names, including aliases, for the `command`.""" + names = [f"{command.full_parent_name} {alias}".strip() for alias in command.aliases] + names.append(command.qualified_name) + + return names + + def get_all_commands(self) -> t.Iterator[commands.Command]: + """Yield all commands for all cogs in all extensions.""" + for module in self.walk_modules(): + for cog in self.walk_cogs(module): + for cmd in self.walk_commands(cog): + yield cmd + + def test_names_dont_shadow(self): + """Names and aliases of commands should be unique.""" + all_names = defaultdict(list) + for cmd in self.get_all_commands(): + func_name = f"{cmd.module}.{cmd.callback.__qualname__}" + + for name in self.get_qualified_names(cmd): + with self.subTest(cmd=func_name, name=name): + if name in all_names: # pragma: no cover + conflicts = ", ".join(all_names.get(name, "")) + self.fail( + f"Name '{name}' of the command {func_name} conflicts with {conflicts}." + ) + + all_names[name].append(func_name) diff --git a/tests/bot/exts/test_duck_pond.py b/tests/bot/exts/test_duck_pond.py new file mode 100644 index 000000000..f6d977482 --- /dev/null +++ b/tests/bot/exts/test_duck_pond.py @@ -0,0 +1,548 @@ +import asyncio +import logging +import typing +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +import discord + +from bot import constants +from bot.exts import duck_pond +from tests import base +from tests import helpers + +MODULE_PATH = "bot.exts.duck_pond" + + +class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): + """Tests for DuckPond functionality.""" + + @classmethod + def setUpClass(cls): + """Sets up the objects that only have to be initialized once.""" + cls.nonstaff_member = helpers.MockMember(name="Non-staffer") + + cls.staff_role = helpers.MockRole(name="Staff role", id=constants.STAFF_ROLES[0]) + cls.staff_member = helpers.MockMember(name="staffer", roles=[cls.staff_role]) + + cls.checkmark_emoji = "\N{White Heavy Check Mark}" + cls.thumbs_up_emoji = "\N{Thumbs Up Sign}" + cls.unicode_duck_emoji = "\N{Duck}" + cls.duck_pond_emoji = helpers.MockPartialEmoji(id=constants.DuckPond.custom_emojis[0]) + cls.non_duck_custom_emoji = helpers.MockPartialEmoji(id=123) + + def setUp(self): + """Sets up the objects that need to be refreshed before each test.""" + self.bot = helpers.MockBot(user=helpers.MockMember(id=46692)) + self.cog = duck_pond.DuckPond(bot=self.bot) + + def test_duck_pond_correctly_initializes(self): + """`__init__ should set `bot` and `webhook_id` attributes and schedule `fetch_webhook`.""" + bot = helpers.MockBot() + cog = MagicMock() + + duck_pond.DuckPond.__init__(cog, bot) + + self.assertEqual(cog.bot, bot) + self.assertEqual(cog.webhook_id, constants.Webhooks.duck_pond) + bot.loop.create_task.assert_called_once_with(cog.fetch_webhook()) + + def test_fetch_webhook_succeeds_without_connectivity_issues(self): + """The `fetch_webhook` method waits until `READY` event and sets the `webhook` attribute.""" + self.bot.fetch_webhook.return_value = "dummy webhook" + self.cog.webhook_id = 1 + + asyncio.run(self.cog.fetch_webhook()) + + self.bot.wait_until_guild_available.assert_called_once() + self.bot.fetch_webhook.assert_called_once_with(1) + self.assertEqual(self.cog.webhook, "dummy webhook") + + def test_fetch_webhook_logs_when_unable_to_fetch_webhook(self): + """The `fetch_webhook` method should log an exception when it fails to fetch the webhook.""" + self.bot.fetch_webhook.side_effect = discord.HTTPException(response=MagicMock(), message="Not found.") + self.cog.webhook_id = 1 + + log = logging.getLogger('bot.exts.duck_pond') + with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: + asyncio.run(self.cog.fetch_webhook()) + + self.bot.wait_until_guild_available.assert_called_once() + self.bot.fetch_webhook.assert_called_once_with(1) + + self.assertEqual(len(log_watcher.records), 1) + + record = log_watcher.records[0] + self.assertEqual(record.levelno, logging.ERROR) + + def test_is_staff_returns_correct_values_based_on_instance_passed(self): + """The `is_staff` method should return correct values based on the instance passed.""" + test_cases = ( + (helpers.MockUser(name="User instance"), False), + (helpers.MockMember(name="Member instance without staff role"), False), + (helpers.MockMember(name="Member instance with staff role", roles=[self.staff_role]), True) + ) + + for user, expected_return in test_cases: + actual_return = self.cog.is_staff(user) + with self.subTest(user_type=user.name, expected_return=expected_return, actual_return=actual_return): + self.assertEqual(expected_return, actual_return) + + async def test_has_green_checkmark_correctly_detects_presence_of_green_checkmark_emoji(self): + """The `has_green_checkmark` method should only return `True` if one is present.""" + test_cases = ( + ( + "No reactions", helpers.MockMessage(), False + ), + ( + "No green check mark reactions", + helpers.MockMessage(reactions=[ + helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]), + helpers.MockReaction(emoji=self.thumbs_up_emoji, users=[self.bot.user]) + ]), + False + ), + ( + "Green check mark reaction, but not from our bot", + helpers.MockMessage(reactions=[ + helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]), + helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.staff_member]) + ]), + False + ), + ( + "Green check mark reaction, with one from the bot", + helpers.MockMessage(reactions=[ + helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]), + helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.staff_member, self.bot.user]) + ]), + True + ) + ) + + for description, message, expected_return in test_cases: + actual_return = await self.cog.has_green_checkmark(message) + with self.subTest( + test_case=description, + expected_return=expected_return, + actual_return=actual_return + ): + self.assertEqual(expected_return, actual_return) + + def _get_reaction( + self, + emoji: typing.Union[str, helpers.MockEmoji], + staff: int = 0, + nonstaff: int = 0 + ) -> helpers.MockReaction: + staffers = [helpers.MockMember(roles=[self.staff_role]) for _ in range(staff)] + nonstaffers = [helpers.MockMember() for _ in range(nonstaff)] + return helpers.MockReaction(emoji=emoji, users=staffers + nonstaffers) + + async def test_count_ducks_correctly_counts_the_number_of_eligible_duck_emojis(self): + """The `count_ducks` method should return the number of unique staffers who gave a duck.""" + test_cases = ( + # Simple test cases + # A message without reactions should return 0 + ( + "No reactions", + helpers.MockMessage(), + 0 + ), + # A message with a non-duck reaction from a non-staffer should return 0 + ( + "Non-duck reaction from non-staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.thumbs_up_emoji, nonstaff=1)]), + 0 + ), + # A message with a non-duck reaction from a staffer should return 0 + ( + "Non-duck reaction from staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.non_duck_custom_emoji, staff=1)]), + 0 + ), + # A message with a non-duck reaction from a non-staffer and staffer should return 0 + ( + "Non-duck reaction from staffer + non-staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.thumbs_up_emoji, staff=1, nonstaff=1)]), + 0 + ), + # A message with a unicode duck reaction from a non-staffer should return 0 + ( + "Unicode Duck Reaction from non-staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, nonstaff=1)]), + 0 + ), + # A message with a unicode duck reaction from a staffer should return 1 + ( + "Unicode Duck Reaction from staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, staff=1)]), + 1 + ), + # A message with a unicode duck reaction from a non-staffer and staffer should return 1 + ( + "Unicode Duck Reaction from staffer + non-staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, staff=1, nonstaff=1)]), + 1 + ), + # A message with a duckpond duck reaction from a non-staffer should return 0 + ( + "Duckpond Duck Reaction from non-staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, nonstaff=1)]), + 0 + ), + # A message with a duckpond duck reaction from a staffer should return 1 + ( + "Duckpond Duck Reaction from staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=1)]), + 1 + ), + # A message with a duckpond duck reaction from a non-staffer and staffer should return 1 + ( + "Duckpond Duck Reaction from staffer + non-staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=1, nonstaff=1)]), + 1 + ), + + # Complex test cases + # A message with duckpond duck reactions from 3 staffers and 2 non-staffers returns 3 + ( + "Duckpond Duck Reaction from 3 staffers + 2 non-staffers", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=3, nonstaff=2)]), + 3 + ), + # A staffer with multiple duck reactions only counts once + ( + "Two different duck reactions from the same staffer", + helpers.MockMessage( + reactions=[ + helpers.MockReaction(emoji=self.duck_pond_emoji, users=[self.staff_member]), + helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.staff_member]), + ] + ), + 1 + ), + # A non-string emoji does not count (to test the `isinstance(reaction.emoji, str)` elif) + ( + "Reaction with non-Emoji/str emoij from 3 staffers + 2 non-staffers", + helpers.MockMessage(reactions=[self._get_reaction(emoji=100, staff=3, nonstaff=2)]), + 0 + ), + # We correctly sum when multiple reactions are provided. + ( + "Duckpond Duck Reaction from 3 staffers + 2 non-staffers", + helpers.MockMessage( + reactions=[ + self._get_reaction(emoji=self.duck_pond_emoji, staff=3, nonstaff=2), + self._get_reaction(emoji=self.unicode_duck_emoji, staff=4, nonstaff=9), + ] + ), + 3 + 4 + ), + ) + + for description, message, expected_count in test_cases: + actual_count = await self.cog.count_ducks(message) + with self.subTest(test_case=description, expected_count=expected_count, actual_count=actual_count): + self.assertEqual(expected_count, actual_count) + + async def test_relay_message_correctly_relays_content_and_attachments(self): + """The `relay_message` method should correctly relay message content and attachments.""" + send_webhook_path = f"{MODULE_PATH}.send_webhook" + send_attachments_path = f"{MODULE_PATH}.send_attachments" + author = MagicMock( + display_name="x", + avatar_url="https://" + ) + + self.cog.webhook = helpers.MockAsyncWebhook() + + test_values = ( + (helpers.MockMessage(author=author, clean_content="", attachments=[]), False, False), + (helpers.MockMessage(author=author, clean_content="message", attachments=[]), True, False), + (helpers.MockMessage(author=author, clean_content="", attachments=["attachment"]), False, True), + (helpers.MockMessage(author=author, clean_content="message", attachments=["attachment"]), True, True), + ) + + for message, expect_webhook_call, expect_attachment_call in test_values: + with patch(send_webhook_path, new_callable=AsyncMock) as send_webhook: + with patch(send_attachments_path, new_callable=AsyncMock) as send_attachments: + with self.subTest(clean_content=message.clean_content, attachments=message.attachments): + await self.cog.relay_message(message) + + self.assertEqual(expect_webhook_call, send_webhook.called) + self.assertEqual(expect_attachment_call, send_attachments.called) + + message.add_reaction.assert_called_once_with(self.checkmark_emoji) + + @patch(f"{MODULE_PATH}.send_attachments", new_callable=AsyncMock) + async def test_relay_message_handles_irretrievable_attachment_exceptions(self, send_attachments): + """The `relay_message` method should handle irretrievable attachments.""" + message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) + side_effects = (discord.errors.Forbidden(MagicMock(), ""), discord.errors.NotFound(MagicMock(), "")) + + self.cog.webhook = helpers.MockAsyncWebhook() + log = logging.getLogger("bot.exts.duck_pond") + + for side_effect in side_effects: # pragma: no cover + send_attachments.side_effect = side_effect + with patch(f"{MODULE_PATH}.send_webhook", new_callable=AsyncMock) as send_webhook: + with self.subTest(side_effect=type(side_effect).__name__): + with self.assertNotLogs(logger=log, level=logging.ERROR): + await self.cog.relay_message(message) + + self.assertEqual(send_webhook.call_count, 2) + + @patch(f"{MODULE_PATH}.send_webhook", new_callable=AsyncMock) + @patch(f"{MODULE_PATH}.send_attachments", new_callable=AsyncMock) + async def test_relay_message_handles_attachment_http_error(self, send_attachments, send_webhook): + """The `relay_message` method should handle irretrievable attachments.""" + message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) + + self.cog.webhook = helpers.MockAsyncWebhook() + log = logging.getLogger("bot.exts.duck_pond") + + side_effect = discord.HTTPException(MagicMock(), "") + send_attachments.side_effect = side_effect + with self.subTest(side_effect=type(side_effect).__name__): + with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: + await self.cog.relay_message(message) + + send_webhook.assert_called_once_with( + webhook=self.cog.webhook, + content=message.clean_content, + username=message.author.display_name, + avatar_url=message.author.avatar_url + ) + + self.assertEqual(len(log_watcher.records), 1) + + record = log_watcher.records[0] + self.assertEqual(record.levelno, logging.ERROR) + + def _mock_payload(self, label: str, is_custom_emoji: bool, id_: int, emoji_name: str): + """Creates a mock `on_raw_reaction_add` payload with the specified emoji data.""" + payload = MagicMock(name=label) + payload.emoji.is_custom_emoji.return_value = is_custom_emoji + payload.emoji.id = id_ + payload.emoji.name = emoji_name + return payload + + async def test_payload_has_duckpond_emoji_correctly_detects_relevant_emojis(self): + """The `on_raw_reaction_add` event handler should ignore irrelevant emojis.""" + test_values = ( + # Custom Emojis + ( + self._mock_payload( + label="Custom Duckpond Emoji", + is_custom_emoji=True, + id_=constants.DuckPond.custom_emojis[0], + emoji_name="" + ), + True + ), + ( + self._mock_payload( + label="Custom Non-Duckpond Emoji", + is_custom_emoji=True, + id_=123, + emoji_name="" + ), + False + ), + # Unicode Emojis + ( + self._mock_payload( + label="Unicode Duck Emoji", + is_custom_emoji=False, + id_=1, + emoji_name=self.unicode_duck_emoji + ), + True + ), + ( + self._mock_payload( + label="Unicode Non-Duck Emoji", + is_custom_emoji=False, + id_=1, + emoji_name=self.thumbs_up_emoji + ), + False + ), + ) + + for payload, expected_return in test_values: + actual_return = self.cog._payload_has_duckpond_emoji(payload) + with self.subTest(case=payload._mock_name, expected_return=expected_return, actual_return=actual_return): + self.assertEqual(expected_return, actual_return) + + @patch(f"{MODULE_PATH}.discord.utils.get") + @patch(f"{MODULE_PATH}.DuckPond._payload_has_duckpond_emoji", new=MagicMock(return_value=False)) + def test_on_raw_reaction_add_returns_early_with_payload_without_duck_emoji(self, utils_get): + """The `on_raw_reaction_add` method should return early if the payload does not contain a duck emoji.""" + self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_add(payload=MagicMock()))) + + # Ensure we've returned before making an unnecessary API call in the lines of code after the emoji check + utils_get.assert_not_called() + + def _raw_reaction_mocks(self, channel_id, message_id, user_id): + """Sets up mocks for tests of the `on_raw_reaction_add` event listener.""" + channel = helpers.MockTextChannel(id=channel_id) + self.bot.get_all_channels.return_value = (channel,) + + message = helpers.MockMessage(id=message_id) + + channel.fetch_message.return_value = message + + member = helpers.MockMember(id=user_id, roles=[self.staff_role]) + message.guild.members = (member,) + + payload = MagicMock(channel_id=channel_id, message_id=message_id, user_id=user_id) + + return channel, message, member, payload + + async def test_on_raw_reaction_add_returns_for_bot_and_non_staff_members(self): + """The `on_raw_reaction_add` event handler should return for bot users or non-staff members.""" + channel_id = 1234 + message_id = 2345 + user_id = 3456 + + channel, message, _, payload = self._raw_reaction_mocks(channel_id, message_id, user_id) + + test_cases = ( + ("non-staff member", helpers.MockMember(id=user_id)), + ("bot staff member", helpers.MockMember(id=user_id, roles=[self.staff_role], bot=True)), + ) + + payload.emoji = self.duck_pond_emoji + + for description, member in test_cases: + message.guild.members = (member, ) + with self.subTest(test_case=description), patch(f"{MODULE_PATH}.DuckPond.has_green_checkmark") as checkmark: + checkmark.side_effect = AssertionError( + "Expected method to return before calling `self.has_green_checkmark`." + ) + self.assertIsNone(await self.cog.on_raw_reaction_add(payload)) + + # Check that we did make it past the payload checks + channel.fetch_message.assert_called_once() + channel.fetch_message.reset_mock() + + @patch(f"{MODULE_PATH}.DuckPond.is_staff") + @patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) + def test_on_raw_reaction_add_returns_on_message_with_green_checkmark_placed_by_bot(self, count_ducks, is_staff): + """The `on_raw_reaction_add` event should return when the message has a green check mark placed by the bot.""" + channel_id = 31415926535 + message_id = 27182818284 + user_id = 16180339887 + + channel, message, member, payload = self._raw_reaction_mocks(channel_id, message_id, user_id) + + payload.emoji = helpers.MockPartialEmoji(name=self.unicode_duck_emoji) + payload.emoji.is_custom_emoji.return_value = False + + message.reactions = [helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.bot.user])] + + is_staff.return_value = True + count_ducks.side_effect = AssertionError("Expected method to return before calling `self.count_ducks`") + + self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_add(payload))) + + # Assert that we've made it past `self.is_staff` + is_staff.assert_called_once() + + async def test_on_raw_reaction_add_does_not_relay_below_duck_threshold(self): + """The `on_raw_reaction_add` listener should not relay messages or attachments below the duck threshold.""" + test_cases = ( + (constants.DuckPond.threshold - 1, False), + (constants.DuckPond.threshold, True), + (constants.DuckPond.threshold + 1, True), + ) + + channel, message, member, payload = self._raw_reaction_mocks(channel_id=3, message_id=4, user_id=5) + + payload.emoji = self.duck_pond_emoji + + for duck_count, should_relay in test_cases: + with patch(f"{MODULE_PATH}.DuckPond.relay_message", new_callable=AsyncMock) as relay_message: + with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) as count_ducks: + count_ducks.return_value = duck_count + with self.subTest(duck_count=duck_count, should_relay=should_relay): + await self.cog.on_raw_reaction_add(payload) + + # Confirm that we've made it past counting + count_ducks.assert_called_once() + + # Did we relay a message? + has_relayed = relay_message.called + self.assertEqual(has_relayed, should_relay) + + if should_relay: + relay_message.assert_called_once_with(message) + + async def test_on_raw_reaction_remove_prevents_removal_of_green_checkmark_depending_on_the_duck_count(self): + """The `on_raw_reaction_remove` listener prevents removal of the check mark on messages with enough ducks.""" + checkmark = helpers.MockPartialEmoji(name=self.checkmark_emoji) + + message = helpers.MockMessage(id=1234) + + channel = helpers.MockTextChannel(id=98765) + channel.fetch_message.return_value = message + + self.bot.get_all_channels.return_value = (channel, ) + + payload = MagicMock(channel_id=channel.id, message_id=message.id, emoji=checkmark) + + test_cases = ( + (constants.DuckPond.threshold - 1, False), + (constants.DuckPond.threshold, True), + (constants.DuckPond.threshold + 1, True), + ) + for duck_count, should_re_add_checkmark in test_cases: + with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) as count_ducks: + count_ducks.return_value = duck_count + with self.subTest(duck_count=duck_count, should_re_add_checkmark=should_re_add_checkmark): + await self.cog.on_raw_reaction_remove(payload) + + # Check if we fetched the message + channel.fetch_message.assert_called_once_with(message.id) + + # Check if we actually counted the number of ducks + count_ducks.assert_called_once_with(message) + + has_re_added_checkmark = message.add_reaction.called + self.assertEqual(should_re_add_checkmark, has_re_added_checkmark) + + if should_re_add_checkmark: + message.add_reaction.assert_called_once_with(self.checkmark_emoji) + message.add_reaction.reset_mock() + + # reset mocks + channel.fetch_message.reset_mock() + message.reset_mock() + + def test_on_raw_reaction_remove_ignores_removal_of_non_checkmark_reactions(self): + """The `on_raw_reaction_remove` listener should ignore the removal of non-check mark emojis.""" + channel = helpers.MockTextChannel(id=98765) + + channel.fetch_message.side_effect = AssertionError( + "Expected method to return before calling `channel.fetch_message`" + ) + + self.bot.get_all_channels.return_value = (channel, ) + + payload = MagicMock(emoji=helpers.MockPartialEmoji(name=self.thumbs_up_emoji), channel_id=channel.id) + + self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_remove(payload))) + + channel.fetch_message.assert_not_called() + + +class DuckPondSetupTests(unittest.TestCase): + """Tests setup of the `DuckPond` cog.""" + + def test_setup(self): + """Setup of the extension should call add_cog.""" + bot = helpers.MockBot() + duck_pond.setup(bot) + bot.add_cog.assert_called_once() diff --git a/tests/bot/exts/utils/__init__.py b/tests/bot/exts/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/exts/utils/test_jams.py b/tests/bot/exts/utils/test_jams.py new file mode 100644 index 000000000..45e7b5b51 --- /dev/null +++ b/tests/bot/exts/utils/test_jams.py @@ -0,0 +1,173 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock, create_autospec + +from discord import CategoryChannel + +from bot.constants import Roles +from bot.exts.utils import jams +from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockTextChannel + + +def get_mock_category(channel_count: int, name: str) -> CategoryChannel: + """Return a mocked code jam category.""" + category = create_autospec(CategoryChannel, spec_set=True, instance=True) + category.name = name + category.channels = [MockTextChannel() for _ in range(channel_count)] + + return category + + +class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): + """Tests for `createteam` command.""" + + def setUp(self): + self.bot = MockBot() + self.admin_role = MockRole(name="Admins", id=Roles.admins) + self.command_user = MockMember([self.admin_role]) + self.guild = MockGuild([self.admin_role]) + self.ctx = MockContext(bot=self.bot, author=self.command_user, guild=self.guild) + self.cog = jams.CodeJams(self.bot) + + async def test_too_small_amount_of_team_members_passed(self): + """Should `ctx.send` and exit early when too small amount of members.""" + for case in (1, 2): + with self.subTest(amount_of_members=case): + self.cog.create_channels = AsyncMock() + self.cog.add_roles = AsyncMock() + + self.ctx.reset_mock() + members = (MockMember() for _ in range(case)) + await self.cog.createteam(self.cog, self.ctx, "foo", members) + + self.ctx.send.assert_awaited_once() + self.cog.create_channels.assert_not_awaited() + self.cog.add_roles.assert_not_awaited() + + async def test_duplicate_members_provided(self): + """Should `ctx.send` and exit early because duplicate members provided and total there is only 1 member.""" + self.cog.create_channels = AsyncMock() + self.cog.add_roles = AsyncMock() + + member = MockMember() + await self.cog.createteam(self.cog, self.ctx, "foo", (member for _ in range(5))) + + self.ctx.send.assert_awaited_once() + self.cog.create_channels.assert_not_awaited() + self.cog.add_roles.assert_not_awaited() + + async def test_result_sending(self): + """Should call `ctx.send` when everything goes right.""" + self.cog.create_channels = AsyncMock() + self.cog.add_roles = AsyncMock() + + members = [MockMember() for _ in range(5)] + await self.cog.createteam(self.cog, self.ctx, "foo", members) + + self.cog.create_channels.assert_awaited_once() + self.cog.add_roles.assert_awaited_once() + self.ctx.send.assert_awaited_once() + + async def test_category_doesnt_exist(self): + """Should create a new code jam category.""" + subtests = ( + [], + [get_mock_category(jams.MAX_CHANNELS - 1, jams.CATEGORY_NAME)], + [get_mock_category(jams.MAX_CHANNELS - 2, "other")], + ) + + for categories in subtests: + self.guild.reset_mock() + self.guild.categories = categories + + with self.subTest(categories=categories): + actual_category = await self.cog.get_category(self.guild) + + self.guild.create_category_channel.assert_awaited_once() + category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"] + + self.assertFalse(category_overwrites[self.guild.default_role].read_messages) + self.assertTrue(category_overwrites[self.guild.me].read_messages) + self.assertEqual(self.guild.create_category_channel.return_value, actual_category) + + async def test_category_channel_exist(self): + """Should not try to create category channel.""" + expected_category = get_mock_category(jams.MAX_CHANNELS - 2, jams.CATEGORY_NAME) + self.guild.categories = [ + get_mock_category(jams.MAX_CHANNELS - 2, "other"), + expected_category, + get_mock_category(0, jams.CATEGORY_NAME), + ] + + actual_category = await self.cog.get_category(self.guild) + self.assertEqual(expected_category, actual_category) + + async def test_channel_overwrites(self): + """Should have correct permission overwrites for users and roles.""" + leader = MockMember() + members = [leader] + [MockMember() for _ in range(4)] + overwrites = self.cog.get_overwrites(members, self.guild) + + # Leader permission overwrites + self.assertTrue(overwrites[leader].manage_messages) + self.assertTrue(overwrites[leader].read_messages) + self.assertTrue(overwrites[leader].manage_webhooks) + self.assertTrue(overwrites[leader].connect) + + # Other members permission overwrites + for member in members[1:]: + self.assertTrue(overwrites[member].read_messages) + self.assertTrue(overwrites[member].connect) + + # Everyone and verified role overwrite + self.assertFalse(overwrites[self.guild.default_role].read_messages) + self.assertFalse(overwrites[self.guild.default_role].connect) + self.assertFalse(overwrites[self.guild.get_role(Roles.verified)].read_messages) + self.assertFalse(overwrites[self.guild.get_role(Roles.verified)].connect) + + async def test_team_channels_creation(self): + """Should create new voice and text channel for team.""" + members = [MockMember() for _ in range(5)] + + self.cog.get_overwrites = MagicMock() + self.cog.get_category = AsyncMock() + self.ctx.guild.create_text_channel.return_value = MockTextChannel(mention="foobar-channel") + actual = await self.cog.create_channels(self.guild, "my-team", members) + + self.assertEqual("foobar-channel", actual) + self.cog.get_overwrites.assert_called_once_with(members, self.guild) + self.cog.get_category.assert_awaited_once_with(self.guild) + + self.guild.create_text_channel.assert_awaited_once_with( + "my-team", + overwrites=self.cog.get_overwrites.return_value, + category=self.cog.get_category.return_value + ) + self.guild.create_voice_channel.assert_awaited_once_with( + "My Team", + overwrites=self.cog.get_overwrites.return_value, + category=self.cog.get_category.return_value + ) + + async def test_jam_roles_adding(self): + """Should add team leader role to leader and jam role to every team member.""" + leader_role = MockRole(name="Team Leader") + jam_role = MockRole(name="Jammer") + self.guild.get_role.side_effect = [leader_role, jam_role] + + leader = MockMember() + members = [leader] + [MockMember() for _ in range(4)] + await self.cog.add_roles(self.guild, members) + + leader.add_roles.assert_any_await(leader_role) + for member in members: + member.add_roles.assert_any_await(jam_role) + + +class CodeJamSetup(unittest.TestCase): + """Test for `setup` function of `CodeJam` cog.""" + + def test_setup(self): + """Should call `bot.add_cog`.""" + bot = MockBot() + jams.setup(bot) + bot.add_cog.assert_called_once() diff --git a/tests/bot/exts/utils/test_snekbox.py b/tests/bot/exts/utils/test_snekbox.py new file mode 100644 index 000000000..f7b861035 --- /dev/null +++ b/tests/bot/exts/utils/test_snekbox.py @@ -0,0 +1,409 @@ +import asyncio +import logging +import unittest +from unittest.mock import AsyncMock, MagicMock, Mock, call, create_autospec, patch + +from discord.ext import commands + +from bot import constants +from bot.exts.utils import snekbox +from bot.exts.utils.snekbox import Snekbox +from tests.helpers import MockBot, MockContext, MockMessage, MockReaction, MockUser + + +class SnekboxTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + """Add mocked bot and cog to the instance.""" + self.bot = MockBot() + self.cog = Snekbox(bot=self.bot) + + async def test_post_eval(self): + """Post the eval code to the URLs.snekbox_eval_api endpoint.""" + resp = MagicMock() + resp.json = AsyncMock(return_value="return") + + context_manager = MagicMock() + context_manager.__aenter__.return_value = resp + self.bot.http_session.post.return_value = context_manager + + self.assertEqual(await self.cog.post_eval("import random"), "return") + self.bot.http_session.post.assert_called_with( + constants.URLs.snekbox_eval_api, + json={"input": "import random"}, + raise_for_status=True + ) + resp.json.assert_awaited_once() + + async def test_upload_output_reject_too_long(self): + """Reject output longer than MAX_PASTE_LEN.""" + result = await self.cog.upload_output("-" * (snekbox.MAX_PASTE_LEN + 1)) + self.assertEqual(result, "too long to upload") + + async def test_upload_output(self): + """Upload the eval output to the URLs.paste_service.format(key="documents") endpoint.""" + key = "MarkDiamond" + resp = MagicMock() + resp.json = AsyncMock(return_value={"key": key}) + + context_manager = MagicMock() + context_manager.__aenter__.return_value = resp + self.bot.http_session.post.return_value = context_manager + + self.assertEqual( + await self.cog.upload_output("My awesome output"), + constants.URLs.paste_service.format(key=key) + ) + self.bot.http_session.post.assert_called_with( + constants.URLs.paste_service.format(key="documents"), + data="My awesome output", + raise_for_status=True + ) + + async def test_upload_output_gracefully_fallback_if_exception_during_request(self): + """Output upload gracefully fallback if the upload fail.""" + resp = MagicMock() + resp.json = AsyncMock(side_effect=Exception) + + context_manager = MagicMock() + context_manager.__aenter__.return_value = resp + self.bot.http_session.post.return_value = context_manager + + log = logging.getLogger("bot.exts.utils.snekbox") + with self.assertLogs(logger=log, level='ERROR'): + await self.cog.upload_output('My awesome output!') + + async def test_upload_output_gracefully_fallback_if_no_key_in_response(self): + """Output upload gracefully fallback if there is no key entry in the response body.""" + self.assertEqual((await self.cog.upload_output('My awesome output!')), None) + + def test_prepare_input(self): + cases = ( + ('print("Hello world!")', 'print("Hello world!")', 'non-formatted'), + ('`print("Hello world!")`', 'print("Hello world!")', 'one line code block'), + ('```\nprint("Hello world!")```', 'print("Hello world!")', 'multiline code block'), + ('```py\nprint("Hello world!")```', 'print("Hello world!")', 'multiline python code block'), + ) + for case, expected, testname in cases: + with self.subTest(msg=f'Extract code from {testname}.'): + self.assertEqual(self.cog.prepare_input(case), expected) + + def test_get_results_message(self): + """Return error and message according to the eval result.""" + cases = ( + ('ERROR', None, ('Your eval job has failed', 'ERROR')), + ('', 128 + snekbox.SIGKILL, ('Your eval job timed out or ran out of memory', '')), + ('', 255, ('Your eval job has failed', 'A fatal NsJail error occurred')) + ) + for stdout, returncode, expected in cases: + with self.subTest(stdout=stdout, returncode=returncode, expected=expected): + actual = self.cog.get_results_message({'stdout': stdout, 'returncode': returncode}) + self.assertEqual(actual, expected) + + @patch('bot.exts.utils.snekbox.Signals', side_effect=ValueError) + def test_get_results_message_invalid_signal(self, mock_signals: Mock): + self.assertEqual( + self.cog.get_results_message({'stdout': '', 'returncode': 127}), + ('Your eval job has completed with return code 127', '') + ) + + @patch('bot.exts.utils.snekbox.Signals') + def test_get_results_message_valid_signal(self, mock_signals: Mock): + mock_signals.return_value.name = 'SIGTEST' + self.assertEqual( + self.cog.get_results_message({'stdout': '', 'returncode': 127}), + ('Your eval job has completed with return code 127 (SIGTEST)', '') + ) + + def test_get_status_emoji(self): + """Return emoji according to the eval result.""" + cases = ( + (' ', -1, ':warning:'), + ('Hello world!', 0, ':white_check_mark:'), + ('Invalid beard size', -1, ':x:') + ) + for stdout, returncode, expected in cases: + with self.subTest(stdout=stdout, returncode=returncode, expected=expected): + actual = self.cog.get_status_emoji({'stdout': stdout, 'returncode': returncode}) + self.assertEqual(actual, expected) + + async def test_format_output(self): + """Test output formatting.""" + self.cog.upload_output = AsyncMock(return_value='https://testificate.com/') + + too_many_lines = ( + '001 | v\n002 | e\n003 | r\n004 | y\n005 | l\n006 | o\n' + '007 | n\n008 | g\n009 | b\n010 | e\n011 | a\n... (truncated - too many lines)' + ) + too_long_too_many_lines = ( + "\n".join( + f"{i:03d} | {line}" for i, line in enumerate(['verylongbeard' * 10] * 15, 1) + )[:1000] + "\n... (truncated - too long, too many lines)" + ) + + cases = ( + ('', ('[No output]', None), 'No output'), + ('My awesome output', ('My awesome output', None), 'One line output'), + ('<@', ("<@\u200B", None), r'Convert <@ to <@\u200B'), + (' Date: Fri, 14 Aug 2020 19:39:07 +0200 Subject: Verification: pause request execution after each batch The Limit values are mostly assumptions, as this feature is very difficult to test at scale. Please see docstring amendmends for further information. --- bot/cogs/verification.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 8f1a773a8..14c0abfda 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -88,6 +88,13 @@ MENTION_UNVERIFIED = discord.AllowedMentions( Request = t.Callable[[discord.Member], t.Awaitable] +class Limit(t.NamedTuple): + """Composition over config for throttling requests.""" + + batch_size: int # Amount of requests after which to pause + sleep_secs: int # Sleep this many seconds after each batch + + def is_verified(member: discord.Member) -> bool: """ Check whether `member` is considered verified. @@ -233,19 +240,22 @@ class Verification(Cog): return result - async def _send_requests(self, members: t.Collection[discord.Member], request: Request) -> int: + async def _send_requests(self, members: t.Collection[discord.Member], request: Request, limit: Limit) -> int: """ Pass `members` one by one to `request` handling Discord exceptions. This coroutine serves as a generic `request` executor for kicking members and adding roles, as it allows us to define the error handling logic in one place only. + To avoid rate-limits, pass a `limit` configuring the batch size and the amount of seconds + to sleep between batches. + Returns the amount of successful requests. Failed requests are logged at info level. """ log.info(f"Sending {len(members)} requests") n_success, bad_statuses = 0, set() - for member in members: + for progress, member in enumerate(members, start=1): if is_verified(member): # Member could have verified in the meantime continue try: @@ -255,6 +265,10 @@ class Verification(Cog): else: n_success += 1 + if progress % limit.batch_size == 0: + log.trace(f"Processed {progress} requests, pausing for {limit.sleep_secs} seconds") + await asyncio.sleep(limit.sleep_secs) + if bad_statuses: log.info(f"Failed to send {len(members) - n_success} requests due to following statuses: {bad_statuses}") @@ -264,6 +278,9 @@ class Verification(Cog): """ Kick `members` from the PyDis guild. + Due to strict ratelimits on sending messages (120 requests / 60 secs), we sleep for a second + after each 2 requests to allow breathing room for other features. + Note that this is a potentially destructive operation. Returns the amount of successful requests. """ log.info(f"Kicking {len(members)} members from the guild (not verified after {KICKED_AFTER} days)") @@ -274,7 +291,7 @@ class Verification(Cog): await member.send(KICKED_MESSAGE) await member.kick(reason=f"User has not verified in {KICKED_AFTER} days") - n_kicked = await self._send_requests(members, kick_request) + n_kicked = await self._send_requests(members, kick_request, Limit(batch_size=2, sleep_secs=1)) self.bot.stats.incr("verification.kicked", count=n_kicked) return n_kicked @@ -283,6 +300,8 @@ class Verification(Cog): """ Give `role` to all `members`. + We pause for a second after batches of 25 requests to ensure ratelimits aren't exceeded. + Returns the amount of successful requests. """ log.info(f"Assigning {role} role to {len(members)} members (not verified after {UNVERIFIED_AFTER} days)") @@ -291,7 +310,7 @@ class Verification(Cog): """Add `role` to `member`.""" await member.add_roles(role, reason=f"User has not verified in {UNVERIFIED_AFTER} days") - return await self._send_requests(members, role_request) + return await self._send_requests(members, role_request, Limit(batch_size=25, sleep_secs=1)) async def _check_members(self) -> t.Tuple[t.Set[discord.Member], t.Set[discord.Member]]: """ -- cgit v1.2.3 From e374c00e2a846cfd9f8c5468b5c2dab599c1f1e2 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Fri, 14 Aug 2020 21:09:51 +0100 Subject: Add constants for badges --- bot/constants.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index d01dcb0fc..f3db80279 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -268,6 +268,17 @@ class Emojis(metaclass=YAMLGetter): status_idle: str status_dnd: str + badge_staff: str + badge_partner: str + badge_hypesquad: str + badge_bug_hunter: str + badge_hypesquad_bravery: str + badge_hypesquad_brilliance: str + badge_hypesquad_balance: str + badge_early_supporter: str + badge_bug_hunter_level_2: str + badge_verified_bot_developer: str + incident_actioned: str incident_unactioned: str incident_investigating: str -- cgit v1.2.3 From f87db13ee549c64723086948b101292da93934d8 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Fri, 14 Aug 2020 21:13:05 +0100 Subject: Add YAML values for badges --- config-default.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/config-default.yml b/config-default.yml index e3ba9fb05..8c0092e76 100644 --- a/config-default.yml +++ b/config-default.yml @@ -38,6 +38,17 @@ style: status_dnd: "<:status_dnd:470326272082313216>" status_offline: "<:status_offline:470326266537705472>" + badge_staff: "<:discord_staff:743882896498098226>" + badge_partner: "<:partner:743882897131569323>" + badge_hypesquad: "<:hypesquad_events:743882896892362873>" + badge_bug_hunter: "<:bug_hunter_lvl1:743882896372269137>" + badge_hypesquad_bravery: "<:hypesquad_bravery:743882896745693335>" + badge_hypesquad_brilliance: "<:hypesquad_brilliance:743882896938631248>" + badge_hypesquad_balance: "<:hypesquad_balance:743882896460480625>" + badge_early_supporter: "<:early_supporter:743882896909140058>" + badge_bug_hunter_level_2: "<:bug_hunter_lvl2:743882896611344505>" + badge_verified_bot_developer: "<:verified_bot_dev:743882897299210310>" + incident_actioned: "<:incident_actioned:719645530128646266>" incident_unactioned: "<:incident_unactioned:719645583245180960>" incident_investigating: "<:incident_investigating:719645658671480924>" -- cgit v1.2.3 From ab133d914e21a298ddd743db578e7d4a2e33120c Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Fri, 14 Aug 2020 21:14:20 +0100 Subject: Add badges & status to user command --- bot/cogs/information.py | 95 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 67 insertions(+), 28 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 8982196d1..34a85a86b 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -4,7 +4,7 @@ import pprint import textwrap from collections import Counter, defaultdict from string import Template -from typing import Any, Mapping, Optional, Union +from typing import Any, Mapping, Optional, Tuple, Union from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, utils from discord.abc import GuildChannel @@ -184,6 +184,18 @@ class Information(Cog): await ctx.send(embed=embed) + @staticmethod + def status_to_emoji(status: Status) -> str: + """Convert a Discord status into the relevant emoji.""" + if status is Status.offline: + return constants.Emojis.status_offline + elif status is Status.dnd: + return constants.Emojis.status_dnd + elif status is Status.idle: + return constants.Emojis.status_idle + else: + return constants.Emojis.status_online + @command(name="user", aliases=["user_info", "member", "member_info"]) async def user_info(self, ctx: Context, user: Member = None) -> None: """Returns info about a user.""" @@ -223,41 +235,68 @@ class Information(Cog): if user.nick: name = f"{user.nick} ({name})" + badges = "" + + for badge, is_set in user.public_flags: + if is_set and (emoji := getattr(constants.Emojis, f"badge_{badge}")): + badges += emoji + " " + joined = time_since(user.joined_at, max_units=3) roles = ", ".join(role.mention for role in user.roles[1:]) - description = [ - textwrap.dedent(f""" - **User Information** - Created: {created} - Profile: {user.mention} - ID: {user.id} - {custom_status} - **Member Information** - Joined: {joined} - Roles: {roles or None} - """).strip() + desktop_status = self.status_to_emoji(user.desktop_status) + web_status = self.status_to_emoji(user.web_status) + mobile_status = self.status_to_emoji(user.mobile_status) + + fields = [ + ( + "User information", + textwrap.dedent(f""" + Created: {created} + Profile: {user.mention} + ID: {user.id} + {custom_status} + """).strip() + ), + ( + "Member information", + textwrap.dedent(f""" + Joined: {joined} + Roles: {roles or None} + """).strip() + ), + ( + "Status", + textwrap.dedent(f""" + Desktop: {desktop_status} + Web: {web_status} + Mobile: {mobile_status} + """).strip() + ) ] # Show more verbose output in moderation channels for infractions and nominations if ctx.channel.id in constants.MODERATION_CHANNELS: - description.append(await self.expanded_user_infraction_counts(user)) - description.append(await self.user_nomination_counts(user)) + fields.append(await self.expanded_user_infraction_counts(user)) + fields.append(await self.user_nomination_counts(user)) else: - description.append(await self.basic_user_infraction_counts(user)) + fields.append(await self.basic_user_infraction_counts(user)) # Let's build the embed now embed = Embed( title=name, - description="\n\n".join(description) + description=badges ) + for field_name, field_content in fields: + embed.add_field(name=field_name, value=field_content, inline=False) + embed.set_thumbnail(url=user.avatar_url_as(static_format="png")) embed.colour = user.top_role.colour if roles else Colour.blurple() return embed - async def basic_user_infraction_counts(self, member: Member) -> str: + async def basic_user_infraction_counts(self, member: Member) -> Tuple[str, str]: """Gets the total and active infraction counts for the given `member`.""" infractions = await self.bot.api_client.get( 'bot/infractions', @@ -270,11 +309,11 @@ class Information(Cog): total_infractions = len(infractions) active_infractions = sum(infraction['active'] for infraction in infractions) - infraction_output = f"**Infractions**\nTotal: {total_infractions}\nActive: {active_infractions}" + infraction_output = f"Total: {total_infractions}\nActive: {active_infractions}" - return infraction_output + return "Infractions", infraction_output - async def expanded_user_infraction_counts(self, member: Member) -> str: + async def expanded_user_infraction_counts(self, member: Member) -> Tuple[str, str]: """ Gets expanded infraction counts for the given `member`. @@ -288,9 +327,9 @@ class Information(Cog): } ) - infraction_output = ["**Infractions**"] + infraction_output = [] if not infractions: - infraction_output.append("This user has never received an infraction.") + infraction_output.append("No infractions") else: # Count infractions split by `type` and `active` status for this user infraction_types = set() @@ -313,9 +352,9 @@ class Information(Cog): infraction_output.append(line) - return "\n".join(infraction_output) + return "Infractions", "\n".join(infraction_output) - async def user_nomination_counts(self, member: Member) -> str: + async def user_nomination_counts(self, member: Member) -> Tuple[str, str]: """Gets the active and historical nomination counts for the given `member`.""" nominations = await self.bot.api_client.get( 'bot/nominations', @@ -324,21 +363,21 @@ class Information(Cog): } ) - output = ["**Nominations**"] + output = [] if not nominations: - output.append("This user has never been nominated.") + output.append("No nominations") else: count = len(nominations) is_currently_nominated = any(nomination["active"] for nomination in nominations) nomination_noun = "nomination" if count == 1 else "nominations" if is_currently_nominated: - output.append(f"This user is **currently** nominated ({count} {nomination_noun} in total).") + output.append(f"This user is **currently** nominated\n({count} {nomination_noun} in total)") else: output.append(f"This user has {count} historical {nomination_noun}, but is currently not nominated.") - return "\n".join(output) + return "Nominations", "\n".join(output) def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str: """Format a mapping to be readable to a human.""" -- cgit v1.2.3 From ed4ebbf5f7ee751f87554831e277d270cf36ac40 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Fri, 14 Aug 2020 21:19:04 +0100 Subject: Update tests for user commands --- tests/bot/cogs/test_information.py | 87 ++++++++++++++++++++++++-------------- 1 file changed, 55 insertions(+), 32 deletions(-) diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 79c0e0ad3..77b0ddf17 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -215,10 +215,10 @@ class UserInfractionHelperMethodTests(unittest.TestCase): with self.subTest(method=method, api_response=api_response, expected_lines=expected_lines): self.bot.api_client.get.return_value = api_response - expected_output = "\n".join(default_header + expected_lines) + expected_output = "\n".join(expected_lines) actual_output = asyncio.run(method(self.member)) - self.assertEqual(expected_output, actual_output) + self.assertEqual((default_header, expected_output), actual_output) def test_basic_user_infraction_counts_returns_correct_strings(self): """The method should correctly list both the total and active number of non-hidden infractions.""" @@ -249,7 +249,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase): }, ) - header = ["**Infractions**"] + header = "Infractions" self._method_subtests(self.cog.basic_user_infraction_counts, test_values, header) @@ -258,7 +258,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase): test_values = ( { "api response": [], - "expected_lines": ["This user has never received an infraction."], + "expected_lines": ["No infractions"], }, # Shows non-hidden inactive infraction as expected { @@ -304,7 +304,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase): }, ) - header = ["**Infractions**"] + header = "Infractions" self._method_subtests(self.cog.expanded_user_infraction_counts, test_values, header) @@ -313,15 +313,15 @@ class UserInfractionHelperMethodTests(unittest.TestCase): test_values = ( { "api response": [], - "expected_lines": ["This user has never been nominated."], + "expected_lines": ["No nominations"], }, { "api response": [{'active': True}], - "expected_lines": ["This user is **currently** nominated (1 nomination in total)."], + "expected_lines": ["This user is **currently** nominated", "(1 nomination in total)"], }, { "api response": [{'active': True}, {'active': False}], - "expected_lines": ["This user is **currently** nominated (2 nominations in total)."], + "expected_lines": ["This user is **currently** nominated", "(2 nominations in total)"], }, { "api response": [{'active': False}], @@ -334,7 +334,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase): ) - header = ["**Nominations**"] + header = "Nominations" self._method_subtests(self.cog.user_nomination_counts, test_values, header) @@ -350,7 +350,10 @@ class UserEmbedTests(unittest.TestCase): self.bot.api_client.get = unittest.mock.AsyncMock() self.cog = information.Information(self.bot) - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + @unittest.mock.patch( + f"{COG_PATH}.basic_user_infraction_counts", + new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) + ) def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self): """The embed should use the string representation of the user if they don't have a nick.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -362,7 +365,10 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.title, "Mr. Hemlock") - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + @unittest.mock.patch( + f"{COG_PATH}.basic_user_infraction_counts", + new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) + ) def test_create_user_embed_uses_nick_in_title_if_available(self): """The embed should use the nick if it's available.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -374,7 +380,10 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)") - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + @unittest.mock.patch( + f"{COG_PATH}.basic_user_infraction_counts", + new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) + ) def test_create_user_embed_ignores_everyone_role(self): """Created `!user` embeds should not contain mention of the @everyone-role.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -386,8 +395,8 @@ class UserEmbedTests(unittest.TestCase): embed = asyncio.run(self.cog.create_user_embed(ctx, user)) - self.assertIn("&Admins", embed.description) - self.assertNotIn("&Everyone", embed.description) + self.assertIn("&Admins", embed.fields[1].value) + self.assertNotIn("&Everyone", embed.fields[1].value) @unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=unittest.mock.AsyncMock) @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=unittest.mock.AsyncMock) @@ -398,8 +407,8 @@ class UserEmbedTests(unittest.TestCase): moderators_role = helpers.MockRole(name='Moderators') moderators_role.colour = 100 - infraction_counts.return_value = "expanded infractions info" - nomination_counts.return_value = "nomination info" + infraction_counts.return_value = ("Infractions", "expanded infractions info") + nomination_counts.return_value = ("Nominations", "nomination info") user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) embed = asyncio.run(self.cog.create_user_embed(ctx, user)) @@ -409,20 +418,19 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual( textwrap.dedent(f""" - **User Information** Created: {"1 year ago"} Profile: {user.mention} ID: {user.id} + """).strip(), + embed.fields[0].value + ) - **Member Information** + self.assertEqual( + textwrap.dedent(f""" Joined: {"1 year ago"} Roles: &Moderators - - expanded infractions info - - nomination info """).strip(), - embed.description + embed.fields[1].value ) @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=unittest.mock.AsyncMock) @@ -433,7 +441,7 @@ class UserEmbedTests(unittest.TestCase): moderators_role = helpers.MockRole(name='Moderators') moderators_role.colour = 100 - infraction_counts.return_value = "basic infractions info" + infraction_counts.return_value = ("Infractions", "basic infractions info") user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) embed = asyncio.run(self.cog.create_user_embed(ctx, user)) @@ -442,21 +450,30 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual( textwrap.dedent(f""" - **User Information** Created: {"1 year ago"} Profile: {user.mention} ID: {user.id} + """).strip(), + embed.fields[0].value + ) - **Member Information** + self.assertEqual( + textwrap.dedent(f""" Joined: {"1 year ago"} Roles: &Moderators - - basic infractions info """).strip(), - embed.description + embed.fields[1].value + ) + + self.assertEqual( + "basic infractions info", + embed.fields[3].value ) - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + @unittest.mock.patch( + f"{COG_PATH}.basic_user_infraction_counts", + new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) + ) def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self): """The embed should be created with the colour of the top role, if a top role is available.""" ctx = helpers.MockContext() @@ -469,7 +486,10 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.colour, discord.Colour(moderators_role.colour)) - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + @unittest.mock.patch( + f"{COG_PATH}.basic_user_infraction_counts", + new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) + ) def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self): """The embed should be created with a blurple colour if the user has no assigned roles.""" ctx = helpers.MockContext() @@ -479,7 +499,10 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.colour, discord.Colour.blurple()) - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + @unittest.mock.patch( + f"{COG_PATH}.basic_user_infraction_counts", + new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) + ) def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self): """The embed thumbnail should be set to the user's avatar in `png` format.""" ctx = helpers.MockContext() -- cgit v1.2.3 From fd403522896eeb5ffdf10eb5fa1dd0616df32486 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Fri, 14 Aug 2020 21:52:57 +0100 Subject: Add status information to user command --- bot/cogs/information.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 34a85a86b..8c5806898 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -6,7 +6,7 @@ from collections import Counter, defaultdict from string import Template from typing import Any, Mapping, Optional, Tuple, Union -from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, utils +from discord import ChannelType, Colour, CustomActivity, Embed, Guild, Member, Message, Role, Status, utils from discord.abc import GuildChannel from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group from discord.utils import escape_markdown @@ -223,13 +223,18 @@ class Information(Cog): # Custom status custom_status = '' for activity in user.activities: - # Check activity.state for None value if user has a custom status set - # This guards against a custom status with an emoji but no text, which will cause - # escape_markdown to raise an exception - # This can be reworked after a move to d.py 1.3.0+, which adds a CustomActivity class - if activity.name == 'Custom Status' and activity.state: - state = escape_markdown(activity.state) - custom_status = f'Status: {state}\n' + if isinstance(activity, CustomActivity): + state = "" + + if activity.name: + state = escape_markdown(activity.name) + + emoji = "" + if activity.emoji: + if not activity.emoji.id: + emoji += activity.emoji.name + " " + + custom_status = f'Status: {emoji}{state}\n' name = str(user) if user.nick: -- cgit v1.2.3 From b7e40706aa152228154ce96f5aa346a9f5fc43db Mon Sep 17 00:00:00 2001 From: AtieP Date: Sat, 15 Aug 2020 10:22:21 +0200 Subject: Add doc cleanup --- bot/cogs/doc.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 204cffb37..63dcc2c15 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -19,7 +19,7 @@ from sphinx.ext import intersphinx from urllib3.exceptions import ProtocolError from bot.bot import Bot -from bot.constants import MODERATION_ROLES, RedirectOutput +from bot.constants import MODERATION_ROLES, RedirectOutput, Emojis from bot.converters import ValidPythonIdentifier, ValidURL from bot.decorators import with_role from bot.pagination import LinePaginator @@ -28,6 +28,8 @@ from bot.pagination import LinePaginator log = logging.getLogger(__name__) logging.getLogger('urllib3').setLevel(logging.WARNING) +DELETE_EMOJI = Emojis.trashcan + # Since Intersphinx is intended to be used with Sphinx, # we need to mock its configuration. SPHINX_MOCK_APP = SimpleNamespace( @@ -66,6 +68,27 @@ FAILED_REQUEST_RETRY_AMOUNT = 3 NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay +async def doc_cleanup(bot: Bot, author: discord.Member, message: discord.Message) -> None: + """ + Runs the cleanup for the documentation command. + + Adds a :trashcan: reaction what, when clicked, will delete the documentation embed. + After a 300 second timeout, the reaction will be removed.""" + + await message.add_reaction(DELETE_EMOJI) + + def check(reaction: discord.Reaction, member: discord.Member) -> bool: + """Check the reaction is :trashcan:, the author is original author and messages are the same.""" + return str(reaction) == DELETE_EMOJI and member.id == author.id and reaction.message.id == message.id + + with suppress(NotFound): + try: + await bot.wait_for("reaction_add", check=check, timeout=300) + await message.delete() + except asyncio.TimeoutError: + await message.remove_reaction(DELETE_EMOJI, bot.user) + + def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable: """ LRU cache implementation for coroutines. @@ -391,7 +414,8 @@ class Doc(commands.Cog): await error_message.delete(delay=NOT_FOUND_DELETE_DELAY) await ctx.message.delete(delay=NOT_FOUND_DELETE_DELAY) else: - await ctx.send(embed=doc_embed) + doc_embed = await ctx.send(embed=doc_embed) + await doc_cleanup(self.bot, ctx.author, doc_embed) @docs_group.command(name='set', aliases=('s',)) @with_role(*MODERATION_ROLES) -- cgit v1.2.3 From 9745f6bdc5d9928cf1cc5d19e3b25da4574d52ec Mon Sep 17 00:00:00 2001 From: AtieP Date: Sat, 15 Aug 2020 10:55:02 +0200 Subject: Satisfy some of the Azure pipelines' code requirements --- bot/cogs/doc.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 63dcc2c15..12ed89004 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -19,7 +19,7 @@ from sphinx.ext import intersphinx from urllib3.exceptions import ProtocolError from bot.bot import Bot -from bot.constants import MODERATION_ROLES, RedirectOutput, Emojis +from bot.constants import Emojis, MODERATION_ROLES, RedirectOutput from bot.converters import ValidPythonIdentifier, ValidURL from bot.decorators import with_role from bot.pagination import LinePaginator @@ -73,8 +73,8 @@ async def doc_cleanup(bot: Bot, author: discord.Member, message: discord.Message Runs the cleanup for the documentation command. Adds a :trashcan: reaction what, when clicked, will delete the documentation embed. - After a 300 second timeout, the reaction will be removed.""" - + After a 300 second timeout, the reaction will be removed. + """ await message.add_reaction(DELETE_EMOJI) def check(reaction: discord.Reaction, member: discord.Member) -> bool: -- cgit v1.2.3 From 7cd29c72c0c074680d63740b79b388da95a50de5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Aug 2020 09:55:43 -0700 Subject: Don't patch ctx.message.author in antispam The modification propagated across all code that is using the same `Message` object, including all other `on_message` listeners. This caused weird bugs e.g. the filtering cog thinking the bot authored a message that triggered a filter. Patching only `ctx.author` means the implementation is more fragile. Infraction code must ensure it only retrieves the author via `ctx.author` and not through `ctx.message`. Fixes #1005 Fixes BOT-7D --- bot/cogs/antispam.py | 1 - bot/cogs/moderation/scheduler.py | 6 ++++-- bot/cogs/moderation/utils.py | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 0bcca578d..bc31cbd95 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -219,7 +219,6 @@ class AntiSpam(Cog): # Get context and make sure the bot becomes the actor of infraction by patching the `author` attributes context = await self.bot.get_context(msg) context.author = self.bot.user - context.message.author = self.bot.user # Since we're going to invoke the tempmute command directly, we need to manually call the converter. dt_remove_role_after = await self.expiration_date_converter.convert(context, f"{remove_role_after}S") diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 75028d851..051f6c52c 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -161,6 +161,7 @@ class InfractionScheduler: self.schedule_expiration(infraction) except discord.HTTPException as e: # Accordingly display that applying the infraction failed. + # Don't use ctx.message.author; antispam only patches ctx.author. confirm_msg = ":x: failed to apply" expiry_msg = "" log_content = ctx.author.mention @@ -190,6 +191,7 @@ class InfractionScheduler: await ctx.send(f"{dm_result}{confirm_msg}{infr_message}.") # Send a log message to the mod log. + # Don't use ctx.message.author for the actor; antispam only patches ctx.author. log.trace(f"Sending apply mod log for infraction #{id_}.") await self.mod_log.send_log_message( icon_url=icon, @@ -198,7 +200,7 @@ class InfractionScheduler: thumbnail=user.avatar_url_as(static_format="png"), text=textwrap.dedent(f""" Member: {user.mention} (`{user.id}`) - Actor: {ctx.message.author}{dm_log_text}{expiry_log_text} + Actor: {ctx.author}{dm_log_text}{expiry_log_text} Reason: {reason} """), content=log_content, @@ -242,7 +244,7 @@ class InfractionScheduler: log_text = await self.deactivate_infraction(response[0], send_log=False) log_text["Member"] = f"{user.mention}(`{user.id}`)" - log_text["Actor"] = str(ctx.message.author) + log_text["Actor"] = str(ctx.author) log_content = None id_ = response[0]['id'] footer = f"ID: {id_}" diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index fb55287b6..f21272102 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -70,7 +70,7 @@ async def post_infraction( log.trace(f"Posting {infr_type} infraction for {user} to the API.") payload = { - "actor": ctx.message.author.id, + "actor": ctx.author.id, # Don't use ctx.message.author; antispam only patches ctx.author. "hidden": hidden, "reason": reason, "type": infr_type, -- cgit v1.2.3 From f26deafbebf1d3f6790a165d403e0fb664117939 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Aug 2020 12:23:26 -0700 Subject: Truncate mod log content Discord has a limit of 2000 characters for messages. --- bot/cogs/moderation/modlog.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 0a63f57b8..5f30d3744 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -120,6 +120,10 @@ class ModLog(Cog, name="ModLog"): else: content = "@everyone" + # Truncate content to 2000 characters and append an ellipsis. + if content and len(content) > 2000: + content = content[:2000 - 3] + "..." + channel = self.bot.get_channel(channel_id) log_message = await channel.send( content=content, -- cgit v1.2.3 From 056936eafc927e8770acdc6f70bf2971cca4f4d2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Aug 2020 12:49:26 -0700 Subject: Escape Markdown in reddit post titles Use a Unicode look-alike character to replace square brackets, since they'd otherwise interfere with the Markdown. Fixes #1030 --- bot/cogs/reddit.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index d853ab2ea..5d9e2c20b 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -10,6 +10,7 @@ from aiohttp import BasicAuth, ClientError from discord import Colour, Embed, TextChannel from discord.ext.commands import Cog, Context, group from discord.ext.tasks import loop +from discord.utils import escape_markdown from bot.bot import Bot from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES, Webhooks @@ -187,6 +188,8 @@ class Reddit(Cog): author = data["author"] title = textwrap.shorten(data["title"], width=64, placeholder="...") + # Normal brackets interfere with Markdown. + title = escape_markdown(title).replace("[", "⦋").replace("]", "⦌") link = self.URL + data["permalink"] embed.description += ( -- cgit v1.2.3 From 063ae2baa0be2d698705dbf896a4e14511416788 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Aug 2020 13:21:02 -0700 Subject: Unnominate banned users from the talent pool Fixes #1065 --- bot/cogs/watchchannels/talentpool.py | 54 +++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 89256e92e..002f01399 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -1,8 +1,9 @@ import logging import textwrap from collections import ChainMap +from typing import Union -from discord import Color, Embed, Member +from discord import Color, Embed, Member, User from discord.ext.commands import Cog, Context, group from bot.api import ResponseCodeError @@ -164,25 +165,10 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): Providing a `reason` is required. """ - active_nomination = await self.bot.api_client.get( - self.api_endpoint, - params=ChainMap( - self.api_default_params, - {"user__id": str(user.id)} - ) - ) - - if not active_nomination: + if await self.unwatch(user.id, reason): + await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed") + else: await ctx.send(":x: The specified user does not have an active nomination") - return - - [nomination] = active_nomination - await self.bot.api_client.patch( - f"{self.api_endpoint}/{nomination['id']}", - json={'end_reason': reason, 'active': False} - ) - await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed") - self._remove_user(user.id) @nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True) @with_role(*MODERATION_ROLES) @@ -220,6 +206,36 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): await ctx.send(f":white_check_mark: Updated the {field} of the nomination!") + @Cog.listener() + async def on_member_ban(self, guild: Guild, user: Union[User, Member]) -> None: + """Remove `user` from the talent pool after they are banned.""" + await self.unwatch(user.id, "User was banned.") + + async def unwatch(self, user_id: int, reason: str) -> bool: + """End the active nomination of a user with the given reason and return True on success.""" + active_nomination = await self.bot.api_client.get( + self.api_endpoint, + params=ChainMap( + self.api_default_params, + {"user__id": str(user_id)} + ) + ) + + if not active_nomination: + log.debug(f"No active nominate exists for {user_id=}") + return False + + log.info(f"Ending nomination: {user_id=} {reason=}") + + [nomination] = active_nomination + await self.bot.api_client.patch( + f"{self.api_endpoint}/{nomination['id']}", + json={'end_reason': reason, 'active': False} + ) + self._remove_user(user_id) + + return True + def _nomination_to_string(self, nomination_object: dict) -> str: """Creates a string representation of a nomination.""" guild = self.bot.get_guild(Guild.id) -- cgit v1.2.3 From 45580f7e98956309681820d23df3d70eb8312f4d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Aug 2020 16:34:48 -0700 Subject: Silence: revoke permissions to add reactions No longer assume default values for the overwrites which are modified. Save and restore previous values `add_reactions` and `send_messages` via redis. When unsilencing, check if a channel is silenced via the redis cache rather than the channel's current overwrites to ensure the task is cancelled even if overwrites were manually edited. --- bot/cogs/moderation/silence.py | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index f8a6592bc..0f3c98306 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -1,4 +1,5 @@ import asyncio +import json import logging from contextlib import suppress from typing import Optional @@ -10,6 +11,7 @@ from discord.ext.commands import Context from bot.bot import Bot from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles from bot.converters import HushDurationConverter +from bot.utils import RedisCache from bot.utils.checks import with_role_check from bot.utils.scheduling import Scheduler @@ -57,10 +59,13 @@ class SilenceNotifier(tasks.Loop): class Silence(commands.Cog): """Commands for stopping channel messages for `verified` role in a channel.""" + # Maps muted channel IDs to their previous overwrites for send_message and add_reactions. + # Overwrites are stored as JSON. + muted_channel_perms = RedisCache() + def __init__(self, bot: Bot): self.bot = bot self.scheduler = Scheduler(self.__class__.__name__) - self.muted_channels = set() self._get_instance_vars_task = self.bot.loop.create_task(self._get_instance_vars()) self._get_instance_vars_event = asyncio.Event() @@ -118,12 +123,17 @@ class Silence(commands.Cog): `duration` is only used for logging; if None is passed `persistent` should be True to not log None. Return `True` if channel permissions were changed, `False` otherwise. """ - current_overwrite = channel.overwrites_for(self._verified_role) - if current_overwrite.send_messages is False: + overwrite = channel.overwrites_for(self._verified_role) + prev_overwrites = dict(send_messages=overwrite.send_messages, add_reactions=overwrite.add_reactions) + + if all(val is False for val in prev_overwrites.values()): log.info(f"Tried to silence channel #{channel} ({channel.id}) but the channel was already silenced.") return False - await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=False)) - self.muted_channels.add(channel) + + overwrite.update(send_messages=False, add_reactions=False) + await channel.set_permissions(self._verified_role, overwrite=overwrite) + await self.muted_channel_perms.set(channel.id, json.dumps(prev_overwrites)) + if persistent: log.info(f"Silenced #{channel} ({channel.id}) indefinitely.") self.notifier.add_channel(channel) @@ -140,22 +150,28 @@ class Silence(commands.Cog): if it is unsilence it and remove it from the notifier. Return `True` if channel permissions were changed, `False` otherwise. """ - current_overwrite = channel.overwrites_for(self._verified_role) - if current_overwrite.send_messages is False: - await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=None)) + prev_overwrites = await self.muted_channel_perms.get(channel.id) + if prev_overwrites is not None: + overwrite = channel.overwrites_for(self._verified_role) + overwrite.update(**json.loads(prev_overwrites)) + + await channel.set_permissions(self._verified_role, overwrite=overwrite) log.info(f"Unsilenced channel #{channel} ({channel.id}).") + self.scheduler.cancel(channel.id) self.notifier.remove_channel(channel) - self.muted_channels.discard(channel) + await self.muted_channel_perms.delete(channel.id) + return True + log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") return False def cog_unload(self) -> None: """Send alert with silenced channels and cancel scheduled tasks on unload.""" self.scheduler.cancel_all() - if self.muted_channels: - channels_string = ''.join(channel.mention for channel in self.muted_channels) + if self.muted_channel_perms: + channels_string = ''.join(channel.mention for channel in self.muted_channel_perms) message = f"<@&{Roles.moderators}> channels left silenced on cog unload: {channels_string}" asyncio.create_task(self._mod_alerts_channel.send(message)) -- cgit v1.2.3 From 1ff8559dda1caa9c8479da5d371ff96aa4797e7c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Aug 2020 18:45:32 -0700 Subject: Silence: abort silence if there's already a scheduled task Overwrites can be edited during a silence, which can result in the overwrites check failing. Checking the scheduler too ensures that a duplicate silence won't be scheduled. --- bot/cogs/moderation/silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 0f3c98306..0f64301c4 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -126,7 +126,7 @@ class Silence(commands.Cog): overwrite = channel.overwrites_for(self._verified_role) prev_overwrites = dict(send_messages=overwrite.send_messages, add_reactions=overwrite.add_reactions) - if all(val is False for val in prev_overwrites.values()): + if channel.id in self.scheduler or all(val is False for val in prev_overwrites.values()): log.info(f"Tried to silence channel #{channel} ({channel.id}) but the channel was already silenced.") return False -- cgit v1.2.3 From 68f2fbcb5afd474bc06c50267655177cf3617c5f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Aug 2020 19:18:24 -0700 Subject: Silence: notify admins if previous overwrites were not cached Admins will have to manually check the default values used and adjust them if they aren't the desired values for that particular channel. --- bot/cogs/moderation/silence.py | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 0f64301c4..ea2f51574 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -146,26 +146,39 @@ class Silence(commands.Cog): """ Unsilence `channel`. - Check if `channel` is silenced through a `PermissionOverwrite`, - if it is unsilence it and remove it from the notifier. + If `channel` has a silence task scheduled or has its previous overwrites cached, unsilence + it, cancel the task, and remove it from the notifier. Notify admins if it has a task but + not cached overwrites. + Return `True` if channel permissions were changed, `False` otherwise. """ prev_overwrites = await self.muted_channel_perms.get(channel.id) - if prev_overwrites is not None: - overwrite = channel.overwrites_for(self._verified_role) + if channel.id not in self.scheduler and prev_overwrites is None: + log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") + return False + + overwrite = channel.overwrites_for(self._verified_role) + if prev_overwrites is None: + log.info(f"Missing previous overwrites for #{channel} ({channel.id}); defaulting to None.") + overwrite.update(send_messages=None, add_reactions=None) + else: overwrite.update(**json.loads(prev_overwrites)) - await channel.set_permissions(self._verified_role, overwrite=overwrite) - log.info(f"Unsilenced channel #{channel} ({channel.id}).") + await channel.set_permissions(self._verified_role, overwrite=overwrite) + log.info(f"Unsilenced channel #{channel} ({channel.id}).") - self.scheduler.cancel(channel.id) - self.notifier.remove_channel(channel) - await self.muted_channel_perms.delete(channel.id) + self.scheduler.cancel(channel.id) + self.notifier.remove_channel(channel) + await self.muted_channel_perms.delete(channel.id) - return True + if prev_overwrites is None: + await self._mod_alerts_channel.send( + f"<@&{Roles.admins}> Restored overwrites with default values after unsilencing " + f"{channel.mention}. Please check that the `Send Messages` and `Add Reactions` " + f"overwrites for {self._verified_role.mention} are at their desired values." + ) - log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") - return False + return True def cog_unload(self) -> None: """Send alert with silenced channels and cancel scheduled tasks on unload.""" -- cgit v1.2.3 From c5617ec5d9ed9f0a76b2b8eb89ed28001a0542f9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Aug 2020 20:21:13 -0700 Subject: Silence: add separate unsilence error for manually-silenced channels It was confusing to reject a silence and an unsilence when overwrites were manually set to False. That's because it's contradictory to show an error stating it's already silence but then reject an unsilence with an error stating the channel isn't silenced. --- bot/cogs/moderation/silence.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index ea2f51574..9863730f4 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -110,8 +110,16 @@ class Silence(commands.Cog): """ await self._get_instance_vars_event.wait() log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") + if not await self._unsilence(ctx.channel): - await ctx.send(f"{Emojis.cross_mark} current channel was not silenced.") + overwrite = ctx.channel.overwrites_for(self._verified_role) + if overwrite.send_messages is False and overwrite.add_reactions is False: + await ctx.send( + f"{Emojis.cross_mark} current channel was not unsilenced because the current " + f"overwrites were set manually. Please edit them manually to unsilence." + ) + else: + await ctx.send(f"{Emojis.cross_mark} current channel was not silenced.") else: await ctx.send(f"{Emojis.check_mark} unsilenced current channel.") -- cgit v1.2.3 From 4df3089d8d03f54cdbd14d7683149ae7931036c1 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 16 Aug 2020 15:28:23 +0200 Subject: Remove the !ask tag --- bot/resources/tags/ask.md | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 bot/resources/tags/ask.md diff --git a/bot/resources/tags/ask.md b/bot/resources/tags/ask.md deleted file mode 100644 index e2c2a88f6..000000000 --- a/bot/resources/tags/ask.md +++ /dev/null @@ -1,9 +0,0 @@ -Asking good questions will yield a much higher chance of a quick response: - -• Don't ask to ask your question, just go ahead and tell us your problem. -• Don't ask if anyone is knowledgeable in some area, filtering serves no purpose. -• Try to solve the problem on your own first, we're not going to write code for you. -• Show us the code you've tried and any errors or unexpected results it's giving. -• Be patient while we're helping you. - -You can find a much more detailed explanation [on our website](https://pythondiscord.com/pages/asking-good-questions/). -- cgit v1.2.3 From 0a865004d7e33a6d379f04b121cf3201411c75a3 Mon Sep 17 00:00:00 2001 From: AtieP Date: Sun, 16 Aug 2020 17:43:34 +0200 Subject: Use wait_for_deletion from /bot/utils/messages.py instead of doc_cleanup --- bot/cogs/doc.py | 28 +++------------------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 12ed89004..a3b1d26a1 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -19,17 +19,16 @@ from sphinx.ext import intersphinx from urllib3.exceptions import ProtocolError from bot.bot import Bot -from bot.constants import Emojis, MODERATION_ROLES, RedirectOutput +from bot.constants import MODERATION_ROLES, RedirectOutput from bot.converters import ValidPythonIdentifier, ValidURL from bot.decorators import with_role from bot.pagination import LinePaginator +from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) logging.getLogger('urllib3').setLevel(logging.WARNING) -DELETE_EMOJI = Emojis.trashcan - # Since Intersphinx is intended to be used with Sphinx, # we need to mock its configuration. SPHINX_MOCK_APP = SimpleNamespace( @@ -68,27 +67,6 @@ FAILED_REQUEST_RETRY_AMOUNT = 3 NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay -async def doc_cleanup(bot: Bot, author: discord.Member, message: discord.Message) -> None: - """ - Runs the cleanup for the documentation command. - - Adds a :trashcan: reaction what, when clicked, will delete the documentation embed. - After a 300 second timeout, the reaction will be removed. - """ - await message.add_reaction(DELETE_EMOJI) - - def check(reaction: discord.Reaction, member: discord.Member) -> bool: - """Check the reaction is :trashcan:, the author is original author and messages are the same.""" - return str(reaction) == DELETE_EMOJI and member.id == author.id and reaction.message.id == message.id - - with suppress(NotFound): - try: - await bot.wait_for("reaction_add", check=check, timeout=300) - await message.delete() - except asyncio.TimeoutError: - await message.remove_reaction(DELETE_EMOJI, bot.user) - - def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable: """ LRU cache implementation for coroutines. @@ -415,7 +393,7 @@ class Doc(commands.Cog): await ctx.message.delete(delay=NOT_FOUND_DELETE_DELAY) else: doc_embed = await ctx.send(embed=doc_embed) - await doc_cleanup(self.bot, ctx.author, doc_embed) + await wait_for_deletion(doc_embed, (ctx.author.id,), client=self.bot) @docs_group.command(name='set', aliases=('s',)) @with_role(*MODERATION_ROLES) -- cgit v1.2.3 From 92094a5f9d4b8cb9693f5e7bd77e69384f25946e Mon Sep 17 00:00:00 2001 From: AtieP Date: Sun, 16 Aug 2020 19:24:27 +0200 Subject: msg rather than doc_embed --- bot/cogs/doc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index a3b1d26a1..30c793c75 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -392,8 +392,8 @@ class Doc(commands.Cog): await error_message.delete(delay=NOT_FOUND_DELETE_DELAY) await ctx.message.delete(delay=NOT_FOUND_DELETE_DELAY) else: - doc_embed = await ctx.send(embed=doc_embed) - await wait_for_deletion(doc_embed, (ctx.author.id,), client=self.bot) + msg = await ctx.send(embed=doc_embed) + await wait_for_deletion(msg, (ctx.author.id,), client=self.bot) @docs_group.command(name='set', aliases=('s',)) @with_role(*MODERATION_ROLES) -- cgit v1.2.3 From 72050ffa47b52daf2fd12ff414b68224e0678fe5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Aug 2020 21:03:10 -0700 Subject: Silence: persist silenced channels Can be used to support rescheduling. --- bot/cogs/moderation/silence.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 9863730f4..c43194511 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -2,6 +2,7 @@ import asyncio import json import logging from contextlib import suppress +from datetime import datetime, timedelta from typing import Optional from discord import TextChannel @@ -63,6 +64,10 @@ class Silence(commands.Cog): # Overwrites are stored as JSON. muted_channel_perms = RedisCache() + # Maps muted channel IDs to POSIX timestamps of when they'll be unsilenced. + # A timestamp equal to -1 means it's indefinite. + muted_channel_times = RedisCache() + def __init__(self, bot: Bot): self.bot = bot self.scheduler = Scheduler(self.__class__.__name__) @@ -90,16 +95,21 @@ class Silence(commands.Cog): """ await self._get_instance_vars_event.wait() log.debug(f"{ctx.author} is silencing channel #{ctx.channel}.") + if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration): await ctx.send(f"{Emojis.cross_mark} current channel is already silenced.") return + if duration is None: await ctx.send(f"{Emojis.check_mark} silenced current channel indefinitely.") + await self.muted_channel_times.set(ctx.channel.id, -1) return await ctx.send(f"{Emojis.check_mark} silenced current channel for {duration} minute(s).") self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence)) + unsilence_time = (datetime.utcnow() + timedelta(minutes=duration)) + await self.muted_channel_times.set(ctx.channel.id, unsilence_time.timestamp()) @commands.command(aliases=("unhush",)) async def unsilence(self, ctx: Context) -> None: @@ -178,6 +188,7 @@ class Silence(commands.Cog): self.scheduler.cancel(channel.id) self.notifier.remove_channel(channel) await self.muted_channel_perms.delete(channel.id) + await self.muted_channel_times.delete(channel.id) if prev_overwrites is None: await self._mod_alerts_channel.send( -- cgit v1.2.3 From 8f4ef1fc0f591f294f46a850d7047fe51a391f1f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Aug 2020 23:11:49 -0700 Subject: Silence: reschedule silences on startup Remove the moderator notification when unloading the cog because. Its purpose was to remind to manually unsilence channels. However, this purpose is now obsolete due to automatic rescheduling. The notification was buggy anyway due to a race condition with the bot shutting down, and that'd be further complicated by having to asynchronously retrieve channels from the redis cache too. Fixes #1053 --- bot/cogs/moderation/silence.py | 42 ++++++++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index c43194511..02d8de29e 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -78,11 +78,14 @@ class Silence(commands.Cog): async def _get_instance_vars(self) -> None: """Get instance variables after they're available to get from the guild.""" await self.bot.wait_until_guild_available() + guild = self.bot.get_guild(Guild.id) self._verified_role = guild.get_role(Roles.verified) self._mod_alerts_channel = self.bot.get_channel(Channels.mod_alerts) self._mod_log_channel = self.bot.get_channel(Channels.mod_log) self.notifier = SilenceNotifier(self._mod_log_channel) + await self._reschedule() + self._get_instance_vars_event.set() @commands.command(aliases=("hush",)) @@ -120,18 +123,21 @@ class Silence(commands.Cog): """ await self._get_instance_vars_event.wait() log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") + await self._unsilence_wrapper(ctx.channel) - if not await self._unsilence(ctx.channel): - overwrite = ctx.channel.overwrites_for(self._verified_role) + async def _unsilence_wrapper(self, channel: TextChannel) -> None: + """Unsilence `channel` and send a success/failure message.""" + if not await self._unsilence(channel): + overwrite = channel.overwrites_for(self._verified_role) if overwrite.send_messages is False and overwrite.add_reactions is False: - await ctx.send( + await channel.send( f"{Emojis.cross_mark} current channel was not unsilenced because the current " f"overwrites were set manually. Please edit them manually to unsilence." ) else: - await ctx.send(f"{Emojis.cross_mark} current channel was not silenced.") + await channel.send(f"{Emojis.cross_mark} current channel was not silenced.") else: - await ctx.send(f"{Emojis.check_mark} unsilenced current channel.") + await channel.send(f"{Emojis.check_mark} unsilenced current channel.") async def _silence(self, channel: TextChannel, persistent: bool, duration: Optional[int]) -> bool: """ @@ -199,13 +205,29 @@ class Silence(commands.Cog): return True + async def _reschedule(self) -> None: + """Reschedule unsilencing of active silences and add permanent ones to the notifier.""" + for channel_id, timestamp in await self.muted_channel_times.items(): + channel = self.bot.get_channel(channel_id) + if channel is None: + log.info(f"Can't reschedule silence for {channel_id}: channel not found.") + continue + + if timestamp == -1: + log.info(f"Adding permanent silence for #{channel} ({channel.id}) to the notifier.") + self.notifier.add_channel(channel) + continue + + dt = datetime.utcfromtimestamp(timestamp) + if dt <= datetime.utcnow(): + await self._unsilence_wrapper(channel) + else: + log.info(f"Rescheduling silence for #{channel} ({channel.id}).") + self.scheduler.schedule_at(dt, channel_id, self._unsilence_wrapper(channel)) + def cog_unload(self) -> None: - """Send alert with silenced channels and cancel scheduled tasks on unload.""" + """Cancel scheduled tasks.""" self.scheduler.cancel_all() - if self.muted_channel_perms: - channels_string = ''.join(channel.mention for channel in self.muted_channel_perms) - message = f"<@&{Roles.moderators}> channels left silenced on cog unload: {channels_string}" - asyncio.create_task(self._mod_alerts_channel.send(message)) # This cannot be static (must have a __func__ attribute). def cog_check(self, ctx: Context) -> bool: -- cgit v1.2.3 From bebe09b74b24e140581cb317f300d2bbad8999df Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Aug 2020 11:06:47 -0700 Subject: Silence: use aware datetimes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `datetime.timestamp()` assumes naïve `datetime`s are in local time, so getting POSIX timestamps in UTC isn't easy for naïve ones. Technically, the timestamp's timezone doesn't matter if all code is on the same page and parsing it with the same timezone. Keeping it in the local timezone would be okay then, but I feel safer locking it to UTC explicitly. --- bot/cogs/moderation/silence.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 02d8de29e..adf469661 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -2,7 +2,7 @@ import asyncio import json import logging from contextlib import suppress -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Optional from discord import TextChannel @@ -111,7 +111,7 @@ class Silence(commands.Cog): await ctx.send(f"{Emojis.check_mark} silenced current channel for {duration} minute(s).") self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence)) - unsilence_time = (datetime.utcnow() + timedelta(minutes=duration)) + unsilence_time = (datetime.now(tz=timezone.utc) + timedelta(minutes=duration)) await self.muted_channel_times.set(ctx.channel.id, unsilence_time.timestamp()) @commands.command(aliases=("unhush",)) @@ -218,12 +218,13 @@ class Silence(commands.Cog): self.notifier.add_channel(channel) continue - dt = datetime.utcfromtimestamp(timestamp) - if dt <= datetime.utcnow(): + dt = datetime.fromtimestamp(timestamp, tz=timezone.utc) + delta = (dt - datetime.now(tz=timezone.utc)).total_seconds() + if delta <= 0: await self._unsilence_wrapper(channel) else: log.info(f"Rescheduling silence for #{channel} ({channel.id}).") - self.scheduler.schedule_at(dt, channel_id, self._unsilence_wrapper(channel)) + self.scheduler.schedule_later(delta, channel_id, self._unsilence_wrapper(channel)) def cog_unload(self) -> None: """Cancel scheduled tasks.""" -- cgit v1.2.3 From 235719b45899f3fb832b86dc578ca4430eeff259 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Aug 2020 11:41:15 -0700 Subject: Silence: remove event and await _get_instance_vars_task directly The event is redundant because the task can be awaited directly to block until it's complete. If the task is already done, the await will instantly finish. --- bot/cogs/moderation/silence.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index adf469661..4910c7009 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -1,4 +1,3 @@ -import asyncio import json import logging from contextlib import suppress @@ -73,7 +72,6 @@ class Silence(commands.Cog): self.scheduler = Scheduler(self.__class__.__name__) self._get_instance_vars_task = self.bot.loop.create_task(self._get_instance_vars()) - self._get_instance_vars_event = asyncio.Event() async def _get_instance_vars(self) -> None: """Get instance variables after they're available to get from the guild.""" @@ -86,8 +84,6 @@ class Silence(commands.Cog): self.notifier = SilenceNotifier(self._mod_log_channel) await self._reschedule() - self._get_instance_vars_event.set() - @commands.command(aliases=("hush",)) async def silence(self, ctx: Context, duration: HushDurationConverter = 10) -> None: """ @@ -96,7 +92,7 @@ class Silence(commands.Cog): Duration is capped at 15 minutes, passing forever makes the silence indefinite. Indefinitely silenced channels get added to a notifier which posts notices every 15 minutes from the start. """ - await self._get_instance_vars_event.wait() + await self._get_instance_vars_task log.debug(f"{ctx.author} is silencing channel #{ctx.channel}.") if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration): @@ -121,7 +117,7 @@ class Silence(commands.Cog): If the channel was silenced indefinitely, notifications for the channel will stop. """ - await self._get_instance_vars_event.wait() + await self._get_instance_vars_task log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") await self._unsilence_wrapper(ctx.channel) -- cgit v1.2.3 From 0a6415068b782d511cafa32002922061a61b9f67 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Aug 2020 11:43:33 -0700 Subject: Silence: rename _get_instance_vars to _init_cog It's a more accurate name since it also reschedules unsilences now. --- bot/cogs/moderation/silence.py | 10 +++++----- tests/bot/cogs/moderation/test_silence.py | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 4910c7009..de799f64f 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -71,10 +71,10 @@ class Silence(commands.Cog): self.bot = bot self.scheduler = Scheduler(self.__class__.__name__) - self._get_instance_vars_task = self.bot.loop.create_task(self._get_instance_vars()) + self._init_task = self.bot.loop.create_task(self._init_cog()) - async def _get_instance_vars(self) -> None: - """Get instance variables after they're available to get from the guild.""" + async def _init_cog(self) -> None: + """Set instance attributes once the guild is available and reschedule unsilences.""" await self.bot.wait_until_guild_available() guild = self.bot.get_guild(Guild.id) @@ -92,7 +92,7 @@ class Silence(commands.Cog): Duration is capped at 15 minutes, passing forever makes the silence indefinite. Indefinitely silenced channels get added to a notifier which posts notices every 15 minutes from the start. """ - await self._get_instance_vars_task + await self._init_task log.debug(f"{ctx.author} is silencing channel #{ctx.channel}.") if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration): @@ -117,7 +117,7 @@ class Silence(commands.Cog): If the channel was silenced indefinitely, notifications for the channel will stop. """ - await self._get_instance_vars_task + await self._init_task log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") await self._unsilence_wrapper(ctx.channel) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index ab3d0742a..7c6efbfe4 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -83,19 +83,19 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_instance_vars_got_guild(self): """Bot got guild after it became available.""" - await self.cog._get_instance_vars() + await self.cog._init_cog() self.bot.wait_until_guild_available.assert_called_once() self.bot.get_guild.assert_called_once_with(Guild.id) async def test_instance_vars_got_role(self): """Got `Roles.verified` role from guild.""" - await self.cog._get_instance_vars() + await self.cog._init_cog() guild = self.bot.get_guild() guild.get_role.assert_called_once_with(Roles.verified) async def test_instance_vars_got_channels(self): """Got channels from bot.""" - await self.cog._get_instance_vars() + await self.cog._init_cog() self.bot.get_channel.called_once_with(Channels.mod_alerts) self.bot.get_channel.called_once_with(Channels.mod_log) @@ -104,7 +104,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): """Notifier was started with channel.""" mod_log = MockTextChannel() self.bot.get_channel.side_effect = (None, mod_log) - await self.cog._get_instance_vars() + await self.cog._init_cog() notifier.assert_called_once_with(mod_log) self.bot.get_channel.side_effect = None -- cgit v1.2.3 From e8bd4f6d2316f351ad2c11b0b4db160939ab6ed5 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sun, 16 Aug 2020 20:59:23 +0100 Subject: Re-align status icons --- bot/cogs/information.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 8c5806898..776a0d474 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -273,9 +273,9 @@ class Information(Cog): ( "Status", textwrap.dedent(f""" - Desktop: {desktop_status} - Web: {web_status} - Mobile: {mobile_status} + {desktop_status} Desktop + {web_status} Web + {mobile_status} Mobile """).strip() ) ] -- cgit v1.2.3 From 2300b66c09ac853fbf332ad1bbdd291d6d0c1d87 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Aug 2020 12:27:24 -0700 Subject: Tests: optionally prevent autospec helper from passing mocks Not everything that's decorated needs the mocks that are patched. Being required to add the args to the test function anyway is annoying. It's especially bad if trying to decorate an entire test suite, as every test would need the args. Move the definition to a separate module to keep things cleaner. --- tests/_autospec.py | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ tests/helpers.py | 21 ++---------------- 2 files changed, 66 insertions(+), 19 deletions(-) create mode 100644 tests/_autospec.py diff --git a/tests/_autospec.py b/tests/_autospec.py new file mode 100644 index 000000000..ee2fc1973 --- /dev/null +++ b/tests/_autospec.py @@ -0,0 +1,64 @@ +import contextlib +import functools +import unittest.mock +from typing import Callable + + +@functools.wraps(unittest.mock._patch.decoration_helper) +@contextlib.contextmanager +def _decoration_helper(self, patched, args, keywargs): + """Skips adding patchings as args if their `dont_pass` attribute is True.""" + # Don't ask what this does. It's just a copy from stdlib, but with the dont_pass check added. + extra_args = [] + with contextlib.ExitStack() as exit_stack: + for patching in patched.patchings: + arg = exit_stack.enter_context(patching) + if not getattr(patching, "dont_pass", False): + # Only add the patching as an arg if dont_pass is False. + if patching.attribute_name is not None: + keywargs.update(arg) + elif patching.new is unittest.mock.DEFAULT: + extra_args.append(arg) + + args += tuple(extra_args) + yield args, keywargs + + +@functools.wraps(unittest.mock._patch.copy) +def _copy(self): + """Copy the `dont_pass` attribute along with the standard copy operation.""" + patcher_copy = _copy.original(self) + patcher_copy.dont_pass = getattr(self, "dont_pass", False) + return patcher_copy + + +# Monkey-patch the patcher class :) +_copy.original = unittest.mock._patch.copy +unittest.mock._patch.copy = _copy +unittest.mock._patch.decoration_helper = _decoration_helper + + +def autospec(target, *attributes: str, pass_mocks: bool = True, **patch_kwargs) -> Callable: + """ + Patch multiple `attributes` of a `target` with autospecced mocks and `spec_set` as True. + + If `pass_mocks` is True, pass the autospecced mocks as arguments to the decorated object. + """ + # Caller's kwargs should take priority and overwrite the defaults. + kwargs = dict(spec_set=True, autospec=True) + kwargs.update(patch_kwargs) + + # Import the target if it's a string. + # This is to support both object and string targets like patch.multiple. + if type(target) is str: + target = unittest.mock._importer(target) + + def decorator(func): + for attribute in attributes: + patcher = unittest.mock.patch.object(target, attribute, **kwargs) + if not pass_mocks: + # A custom attribute to keep track of which patchings should be skipped. + patcher.dont_pass = True + func = patcher(func) + return func + return decorator diff --git a/tests/helpers.py b/tests/helpers.py index facc4e1af..6cf5d12bd 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -5,7 +5,7 @@ import itertools import logging import unittest.mock from asyncio import AbstractEventLoop -from typing import Callable, Iterable, Optional +from typing import Iterable, Optional import discord from aiohttp import ClientSession @@ -14,6 +14,7 @@ from discord.ext.commands import Context from bot.api import APIClient from bot.async_stats import AsyncStatsClient from bot.bot import Bot +from tests._autospec import autospec # noqa: F401 other modules import it via this module for logger in logging.Logger.manager.loggerDict.values(): @@ -26,24 +27,6 @@ for logger in logging.Logger.manager.loggerDict.values(): logger.setLevel(logging.CRITICAL) -def autospec(target, *attributes: str, **kwargs) -> Callable: - """Patch multiple `attributes` of a `target` with autospecced mocks and `spec_set` as True.""" - # Caller's kwargs should take priority and overwrite the defaults. - kwargs = {'spec_set': True, 'autospec': True, **kwargs} - - # Import the target if it's a string. - # This is to support both object and string targets like patch.multiple. - if type(target) is str: - target = unittest.mock._importer(target) - - def decorator(func): - for attribute in attributes: - patcher = unittest.mock.patch.object(target, attribute, **kwargs) - func = patcher(func) - return func - return decorator - - class HashableMixin(discord.mixins.EqualityComparable): """ Mixin that provides similar hashing and equality functionality as discord.py's `Hashable` mixin. -- cgit v1.2.3 From 0ffa80717d9db89ab6ed8865741c39820d33b392 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Aug 2020 12:11:21 -0700 Subject: Silence tests: mock RedisCaches --- tests/bot/cogs/moderation/test_silence.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 7c6efbfe4..8dfebb95c 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -6,7 +6,7 @@ from discord import PermissionOverwrite from bot.cogs.moderation.silence import Silence, SilenceNotifier from bot.constants import Channels, Emojis, Guild, Roles -from tests.helpers import MockBot, MockContext, MockTextChannel +from tests.helpers import MockBot, MockContext, MockTextChannel, autospec class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): @@ -72,14 +72,13 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): self.alert_channel.send.assert_not_called() +@autospec(Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) class SilenceTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.bot = MockBot() self.cog = Silence(self.bot) self.ctx = MockContext() self.cog._verified_role = None - # Set event so command callbacks can continue. - self.cog._get_instance_vars_event.set() async def test_instance_vars_got_guild(self): """Bot got guild after it became available.""" -- cgit v1.2.3 From 19f801289b14740017687a72d106875276507a1c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Aug 2020 12:12:34 -0700 Subject: Silence tests: rename test_instance_vars to test_init_cog --- tests/bot/cogs/moderation/test_silence.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 8dfebb95c..67a61382c 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -80,26 +80,26 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.ctx = MockContext() self.cog._verified_role = None - async def test_instance_vars_got_guild(self): + async def test_init_cog_got_guild(self): """Bot got guild after it became available.""" await self.cog._init_cog() - self.bot.wait_until_guild_available.assert_called_once() + self.bot.wait_until_guild_available.assert_awaited_once() self.bot.get_guild.assert_called_once_with(Guild.id) - async def test_instance_vars_got_role(self): + async def test_init_cog_got_role(self): """Got `Roles.verified` role from guild.""" await self.cog._init_cog() guild = self.bot.get_guild() guild.get_role.assert_called_once_with(Roles.verified) - async def test_instance_vars_got_channels(self): + async def test_init_cog_got_channels(self): """Got channels from bot.""" await self.cog._init_cog() self.bot.get_channel.called_once_with(Channels.mod_alerts) self.bot.get_channel.called_once_with(Channels.mod_log) @mock.patch("bot.cogs.moderation.silence.SilenceNotifier") - async def test_instance_vars_got_notifier(self, notifier): + async def test_init_cog_got_notifier(self, notifier): """Notifier was started with channel.""" mod_log = MockTextChannel() self.bot.get_channel.side_effect = (None, mod_log) -- cgit v1.2.3 From de92d8553de25cc1ec45a5e4dbee8fd4d3426fa8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Aug 2020 12:15:02 -0700 Subject: Silence tests: replace obsolete cog_unload tests Moderation notifications are no longer sent so that doesn't need to be tested. --- tests/bot/cogs/moderation/test_silence.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 67a61382c..807bb09a0 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -74,6 +74,7 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): @autospec(Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) class SilenceTests(unittest.IsolatedAsyncioTestCase): + @autospec("bot.cogs.moderation.silence", "Scheduler", pass_mocks=False) def setUp(self) -> None: self.bot = MockBot() self.cog = Silence(self.bot) @@ -237,20 +238,10 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): del mock_permissions_dict['send_messages'] self.assertDictEqual(mock_permissions_dict, new_permissions) - @mock.patch("bot.cogs.moderation.silence.asyncio") - @mock.patch.object(Silence, "_mod_alerts_channel", create=True) - def test_cog_unload_starts_task(self, alert_channel, asyncio_mock): - """Task for sending an alert was created with present `muted_channels`.""" - with mock.patch.object(self.cog, "muted_channels"): - self.cog.cog_unload() - alert_channel.send.assert_called_once_with(f"<@&{Roles.moderators}> channels left silenced on cog unload: ") - asyncio_mock.create_task.assert_called_once_with(alert_channel.send()) - - @mock.patch("bot.cogs.moderation.silence.asyncio") - def test_cog_unload_skips_task_start(self, asyncio_mock): - """No task created with no channels.""" + def test_cog_unload_cancels_tasks(self): + """All scheduled tasks should be cancelled.""" self.cog.cog_unload() - asyncio_mock.create_task.assert_not_called() + self.cog.scheduler.cancel_all.assert_called_once_with() @mock.patch("bot.cogs.moderation.silence.with_role_check") @mock.patch("bot.cogs.moderation.silence.MODERATION_ROLES", new=(1, 2, 3)) -- cgit v1.2.3 From 3182bacc342fd87313ffb22742947576d887f73b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 13:18:58 -0700 Subject: Silence tests: fix silence cache test for overwrites --- tests/bot/cogs/moderation/test_silence.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 807bb09a0..6f0cd880d 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -184,12 +184,15 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._silence(channel, False, None) self.cog.notifier.add_channel.assert_not_called() - async def test_silence_private_added_muted_channel(self): - """Channel was added to `muted_channels` on silence.""" + async def test_silence_private_cached_perms(self): + """Channel's previous overwrites were cached when silenced.""" channel = MockTextChannel() - with mock.patch.object(self.cog, "muted_channels") as muted_channels: - await self.cog._silence(channel, False, None) - muted_channels.add.assert_called_once_with(channel) + overwrite = PermissionOverwrite(send_messages=True, add_reactions=None) + overwrite_json = '{"send_messages": true, "add_reactions": null}' + channel.overwrites_for.return_value = overwrite + + await self.cog._silence(channel, False, None) + self.cog.muted_channel_perms.set.assert_called_once_with(channel.id, overwrite_json) async def test_unsilence_private_for_false(self): """Permissions are not set and `False` is returned in an unsilenced channel.""" -- cgit v1.2.3 From ccbc515cfedd5938f4d1d50404a1b32d66bd5511 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 13:29:16 -0700 Subject: Silence tests: fix test_unsilence_private_for_false --- tests/bot/cogs/moderation/test_silence.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 6f0cd880d..c1039f85c 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -196,7 +196,10 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_unsilence_private_for_false(self): """Permissions are not set and `False` is returned in an unsilenced channel.""" - channel = Mock() + self.cog.scheduler.__contains__.return_value = False + self.cog.muted_channel_perms.get.return_value = None + channel = MockTextChannel() + self.assertFalse(await self.cog._unsilence(channel)) channel.set_permissions.assert_not_called() -- cgit v1.2.3 From 28f7f66e4e9543d79f5a64ee6a0e0bbc80088804 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 13:33:47 -0700 Subject: Silence tests: fix test_silence_private_notifier --- tests/bot/cogs/moderation/test_silence.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index c1039f85c..e2e3ca9c1 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -174,6 +174,9 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_silence_private_notifier(self): """Channel should be added to notifier with `persistent` set to `True`, and the other way around.""" channel = MockTextChannel() + overwrite = PermissionOverwrite(send_messages=True, add_reactions=None) + channel.overwrites_for.return_value = overwrite + with mock.patch.object(self.cog, "notifier", create=True): with self.subTest(persistent=True): await self.cog._silence(channel, True, None) -- cgit v1.2.3 From c24fa4371c9e8b13431b5d5f3808401dda8d449a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 14:20:05 -0700 Subject: Silence tests: fix test_silence_private_silenced_channel --- tests/bot/cogs/moderation/test_silence.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index e2e3ca9c1..75b4ef470 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -151,11 +151,18 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): channel.set_permissions.assert_not_called() async def test_silence_private_silenced_channel(self): - """Channel had `send_message` permissions revoked.""" + """Channel had `send_message` and `add_reactions` permissions revoked for verified role.""" channel = MockTextChannel() + overwrite = PermissionOverwrite(send_messages=True, add_reactions=None) + channel.overwrites_for.return_value = overwrite + self.assertTrue(await self.cog._silence(channel, False, None)) - channel.set_permissions.assert_called_once() - self.assertFalse(channel.set_permissions.call_args.kwargs['send_messages']) + self.assertFalse(overwrite.send_messages) + self.assertFalse(overwrite.add_reactions) + channel.set_permissions.assert_awaited_once_with( + self.cog._verified_role, + overwrite=overwrite + ) async def test_silence_private_preserves_permissions(self): """Previous permissions were preserved when channel was silenced.""" -- cgit v1.2.3 From 830bb36654103474fa74505f3e0ff4bdf91656fd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 14:28:02 -0700 Subject: Silence tests: fix test_silence_private_for_false --- tests/bot/cogs/moderation/test_silence.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 75b4ef470..5763c4cdd 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -144,11 +144,20 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_silence_private_for_false(self): """Permissions are not set and `False` is returned in an already silenced channel.""" - perm_overwrite = Mock(send_messages=False) - channel = Mock(overwrites_for=Mock(return_value=perm_overwrite)) + subtests = ( + (False, PermissionOverwrite(send_messages=False, add_reactions=False)), + (True, PermissionOverwrite(send_messages=True, add_reactions=True)), + (True, PermissionOverwrite(send_messages=False, add_reactions=False)), + ) - self.assertFalse(await self.cog._silence(channel, True, None)) - channel.set_permissions.assert_not_called() + for contains, overwrite in subtests: + with self.subTest(contains=contains, overwrite=overwrite): + self.cog.scheduler.__contains__.return_value = contains + channel = MockTextChannel() + channel.overwrites_for.return_value = overwrite + + self.assertFalse(await self.cog._silence(channel, True, None)) + channel.set_permissions.assert_not_called() async def test_silence_private_silenced_channel(self): """Channel had `send_message` and `add_reactions` permissions revoked for verified role.""" -- cgit v1.2.3 From f8485f17ac0263c47365893438b3f1da609cb259 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 14:47:28 -0700 Subject: Silence tests: fix command message tests --- tests/bot/cogs/moderation/test_silence.py | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 5763c4cdd..02964d7ab 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -115,15 +115,12 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): (None, f"{Emojis.check_mark} silenced current channel indefinitely.", True,), (5, f"{Emojis.cross_mark} current channel is already silenced.", False,), ) - for duration, result_message, _silence_patch_return in test_cases: - with self.subTest( - silence_duration=duration, - result_message=result_message, - starting_unsilenced_state=_silence_patch_return - ): - with mock.patch.object(self.cog, "_silence", return_value=_silence_patch_return): + for duration, message, was_silenced in test_cases: + self.cog._init_task = mock.AsyncMock()() + with self.subTest(was_silenced=was_silenced, message=message, duration=duration): + with mock.patch.object(self.cog, "_silence", return_value=was_silenced): await self.cog.silence.callback(self.cog, self.ctx, duration) - self.ctx.send.assert_called_once_with(result_message) + self.ctx.send.assert_called_once_with(message) self.ctx.reset_mock() async def test_unsilence_sent_correct_discord_message(self): @@ -132,14 +129,12 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): (True, f"{Emojis.check_mark} unsilenced current channel."), (False, f"{Emojis.cross_mark} current channel was not silenced.") ) - for _unsilence_patch_return, result_message in test_cases: - with self.subTest( - starting_silenced_state=_unsilence_patch_return, - result_message=result_message - ): - with mock.patch.object(self.cog, "_unsilence", return_value=_unsilence_patch_return): + for was_unsilenced, message in test_cases: + self.cog._init_task = mock.AsyncMock()() + with self.subTest(was_unsilenced=was_unsilenced, message=message): + with mock.patch.object(self.cog, "_unsilence", return_value=was_unsilenced): await self.cog.unsilence.callback(self.cog, self.ctx) - self.ctx.send.assert_called_once_with(result_message) + self.ctx.channel.send.assert_called_once_with(message) self.ctx.reset_mock() async def test_silence_private_for_false(self): -- cgit v1.2.3 From 89107eccca3213c436028b997bdc6785fa9ce02d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 15:09:31 -0700 Subject: Silence tests: fix overwrite preservation test for silences --- tests/bot/cogs/moderation/test_silence.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 02964d7ab..765c324d2 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -168,19 +168,23 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): overwrite=overwrite ) - async def test_silence_private_preserves_permissions(self): - """Previous permissions were preserved when channel was silenced.""" + async def test_silence_private_preserves_other_overwrites(self): + """Channel's other unrelated overwrites were not changed when it was silenced.""" channel = MockTextChannel() - # Set up mock channel permission state. - mock_permissions = PermissionOverwrite() - mock_permissions_dict = dict(mock_permissions) - channel.overwrites_for.return_value = mock_permissions + overwrite = PermissionOverwrite(stream=True, attach_files=False) + channel.overwrites_for.return_value = overwrite + + prev_overwrite_dict = dict(overwrite) await self.cog._silence(channel, False, None) - new_permissions = channel.set_permissions.call_args.kwargs - # Remove 'send_messages' key because it got changed in the method. - del new_permissions['send_messages'] - del mock_permissions_dict['send_messages'] - self.assertDictEqual(mock_permissions_dict, new_permissions) + new_overwrite_dict = dict(overwrite) + + # Remove 'send_messages' & 'add_reactions' keys because they were changed by the method. + del prev_overwrite_dict['send_messages'] + del prev_overwrite_dict['add_reactions'] + del new_overwrite_dict['send_messages'] + del new_overwrite_dict['add_reactions'] + + self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) async def test_silence_private_notifier(self): """Channel should be added to notifier with `persistent` set to `True`, and the other way around.""" -- cgit v1.2.3 From eff788aab84ee96408823eb61ebafba2d9e9cbcb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 15:39:00 -0700 Subject: Silence tests: fix test_unsilence_private_removed_notifier --- tests/bot/cogs/moderation/test_silence.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 765c324d2..b21f5f61a 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -233,8 +233,11 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): @mock.patch.object(Silence, "notifier", create=True) async def test_unsilence_private_removed_notifier(self, notifier): """Channel was removed from `notifier` on unsilence.""" - perm_overwrite = MagicMock(send_messages=False) - channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) + overwrite_json = '{"send_messages": true, "add_reactions": null}' + self.cog.muted_channel_perms.get.return_value = overwrite_json + channel = MockTextChannel() + channel.overwrites_for.return_value = PermissionOverwrite() + await self.cog._unsilence(channel) notifier.remove_channel.assert_called_once_with(channel) -- cgit v1.2.3 From 3ec17dac3fe8e00f34489a5e3dd927bec39e91e6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 15:58:31 -0700 Subject: Silence tests: fix tests for _unsilence Add a fixture to set up mocks for a successful `unsilence` call. This reduces code redundancy. --- tests/bot/cogs/moderation/test_silence.py | 75 ++++++++++++++++++------------- 1 file changed, 45 insertions(+), 30 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index b21f5f61a..ff32e9a05 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -1,6 +1,6 @@ import unittest from unittest import mock -from unittest.mock import MagicMock, Mock +from unittest.mock import Mock from discord import PermissionOverwrite @@ -81,6 +81,18 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.ctx = MockContext() self.cog._verified_role = None + def unsilence_fixture(self) -> MockTextChannel: + """Setup mocks for a successful `_unsilence` call. Return the mocked channel.""" + overwrite_json = '{"send_messages": true, "add_reactions": null}' + self.cog.muted_channel_perms.get.return_value = overwrite_json + + # stream=True just to have at least one other overwrite not be the default value. + channel = MockTextChannel() + overwrite = PermissionOverwrite(stream=True, send_messages=False, add_reactions=False) + channel.overwrites_for.return_value = overwrite + + return channel + async def test_init_cog_got_guild(self): """Bot got guild after it became available.""" await self.cog._init_cog() @@ -223,47 +235,50 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): @mock.patch.object(Silence, "notifier", create=True) async def test_unsilence_private_unsilenced_channel(self, _): - """Channel had `send_message` permissions restored""" - perm_overwrite = MagicMock(send_messages=False) - channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) - self.assertTrue(await self.cog._unsilence(channel)) - channel.set_permissions.assert_called_once() - self.assertIsNone(channel.set_permissions.call_args.kwargs['send_messages']) + """Channel had `send_message` permissions restored.""" + channel = self.unsilence_fixture() + overwrite = channel.overwrites_for.return_value + + await self.cog._unsilence(channel) + channel.set_permissions.assert_awaited_once_with( + self.cog._verified_role, overwrite=overwrite + ) + + # Recall that these values are determined by the fixture. + self.assertTrue(overwrite.send_messages) + self.assertIsNone(overwrite.add_reactions) @mock.patch.object(Silence, "notifier", create=True) async def test_unsilence_private_removed_notifier(self, notifier): """Channel was removed from `notifier` on unsilence.""" - overwrite_json = '{"send_messages": true, "add_reactions": null}' - self.cog.muted_channel_perms.get.return_value = overwrite_json - channel = MockTextChannel() - channel.overwrites_for.return_value = PermissionOverwrite() - + channel = self.unsilence_fixture() await self.cog._unsilence(channel) notifier.remove_channel.assert_called_once_with(channel) @mock.patch.object(Silence, "notifier", create=True) async def test_unsilence_private_removed_muted_channel(self, _): - """Channel was removed from `muted_channels` on unsilence.""" - perm_overwrite = MagicMock(send_messages=False) - channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) - with mock.patch.object(self.cog, "muted_channels") as muted_channels: - await self.cog._unsilence(channel) - muted_channels.discard.assert_called_once_with(channel) + """Channel was removed from overwrites cache on unsilence.""" + channel = self.unsilence_fixture() + await self.cog._unsilence(channel) + self.cog.muted_channel_perms.delete.assert_awaited_once_with(channel.id) @mock.patch.object(Silence, "notifier", create=True) - async def test_unsilence_private_preserves_permissions(self, _): - """Previous permissions were preserved when channel was unsilenced.""" - channel = MockTextChannel() - # Set up mock channel permission state. - mock_permissions = PermissionOverwrite(send_messages=False) - mock_permissions_dict = dict(mock_permissions) - channel.overwrites_for.return_value = mock_permissions + async def test_unsilence_private_preserves_other_overwrites(self, _): + """Channel's other unrelated overwrites were not changed when it was unsilenced.""" + channel = self.unsilence_fixture() + overwrite = channel.overwrites_for.return_value + + prev_overwrite_dict = dict(overwrite) await self.cog._unsilence(channel) - new_permissions = channel.set_permissions.call_args.kwargs - # Remove 'send_messages' key because it got changed in the method. - del new_permissions['send_messages'] - del mock_permissions_dict['send_messages'] - self.assertDictEqual(mock_permissions_dict, new_permissions) + new_overwrite_dict = dict(overwrite) + + # Remove 'send_messages' & 'add_reactions' keys because they were changed by the method. + del prev_overwrite_dict['send_messages'] + del prev_overwrite_dict['add_reactions'] + del new_overwrite_dict['send_messages'] + del new_overwrite_dict['add_reactions'] + + self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) def test_cog_unload_cancels_tasks(self): """All scheduled tasks should be cancelled.""" -- cgit v1.2.3 From 0adaf09af5c7a76566f874eda429dd6f263189be Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 16:33:39 -0700 Subject: Silence tests: separate test cases; refactor names & docstrings --- tests/bot/cogs/moderation/test_silence.py | 166 +++++++++++++++++------------- 1 file changed, 95 insertions(+), 71 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index ff32e9a05..f3ee184d4 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -1,3 +1,4 @@ +import asyncio import unittest from unittest import mock from unittest.mock import Mock @@ -73,25 +74,13 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): @autospec(Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) -class SilenceTests(unittest.IsolatedAsyncioTestCase): +class SilenceCogTests(unittest.IsolatedAsyncioTestCase): + """Tests for the general functionality of the Silence cog.""" + @autospec("bot.cogs.moderation.silence", "Scheduler", pass_mocks=False) def setUp(self) -> None: self.bot = MockBot() self.cog = Silence(self.bot) - self.ctx = MockContext() - self.cog._verified_role = None - - def unsilence_fixture(self) -> MockTextChannel: - """Setup mocks for a successful `_unsilence` call. Return the mocked channel.""" - overwrite_json = '{"send_messages": true, "add_reactions": null}' - self.cog.muted_channel_perms.get.return_value = overwrite_json - - # stream=True just to have at least one other overwrite not be the default value. - channel = MockTextChannel() - overwrite = PermissionOverwrite(stream=True, send_messages=False, add_reactions=False) - channel.overwrites_for.return_value = overwrite - - return channel async def test_init_cog_got_guild(self): """Bot got guild after it became available.""" @@ -120,37 +109,49 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): notifier.assert_called_once_with(mod_log) self.bot.get_channel.side_effect = None - async def test_silence_sent_correct_discord_message(self): - """Check if proper message was sent when called with duration in channel with previous state.""" + def test_cog_unload_cancelled_tasks(self): + """All scheduled tasks were cancelled.""" + self.cog.cog_unload() + self.cog.scheduler.cancel_all.assert_called_once_with() + + @mock.patch("bot.cogs.moderation.silence.with_role_check") + @mock.patch("bot.cogs.moderation.silence.MODERATION_ROLES", new=(1, 2, 3)) + def test_cog_check(self, role_check): + """Role check was called with `MODERATION_ROLES`""" + ctx = MockContext() + self.cog.cog_check(ctx) + role_check.assert_called_once_with(ctx, *(1, 2, 3)) + + +@autospec(Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) +class SilenceTests(unittest.IsolatedAsyncioTestCase): + """Tests for the silence command and its related helper methods.""" + + @autospec(Silence, "_reschedule", pass_mocks=False) + @autospec("bot.cogs.moderation.silence", "Scheduler", "SilenceNotifier", pass_mocks=False) + def setUp(self) -> None: + self.bot = MockBot() + self.cog = Silence(self.bot) + self.cog._init_task = mock.AsyncMock()() + + asyncio.run(self.cog._init_cog()) # Populate instance attributes. + + async def test_sent_correct_message(self): + """Appropriate failure/success message was sent by the command.""" test_cases = ( (0.0001, f"{Emojis.check_mark} silenced current channel for 0.0001 minute(s).", True,), (None, f"{Emojis.check_mark} silenced current channel indefinitely.", True,), (5, f"{Emojis.cross_mark} current channel is already silenced.", False,), ) for duration, message, was_silenced in test_cases: - self.cog._init_task = mock.AsyncMock()() + ctx = MockContext() with self.subTest(was_silenced=was_silenced, message=message, duration=duration): with mock.patch.object(self.cog, "_silence", return_value=was_silenced): - await self.cog.silence.callback(self.cog, self.ctx, duration) - self.ctx.send.assert_called_once_with(message) - self.ctx.reset_mock() + await self.cog.silence.callback(self.cog, ctx, duration) + ctx.send.assert_called_once_with(message) - async def test_unsilence_sent_correct_discord_message(self): - """Check if proper message was sent when unsilencing channel.""" - test_cases = ( - (True, f"{Emojis.check_mark} unsilenced current channel."), - (False, f"{Emojis.cross_mark} current channel was not silenced.") - ) - for was_unsilenced, message in test_cases: - self.cog._init_task = mock.AsyncMock()() - with self.subTest(was_unsilenced=was_unsilenced, message=message): - with mock.patch.object(self.cog, "_unsilence", return_value=was_unsilenced): - await self.cog.unsilence.callback(self.cog, self.ctx) - self.ctx.channel.send.assert_called_once_with(message) - self.ctx.reset_mock() - - async def test_silence_private_for_false(self): - """Permissions are not set and `False` is returned in an already silenced channel.""" + async def test_skipped_already_silenced(self): + """Permissions were not set and `False` was returned for an already silenced channel.""" subtests = ( (False, PermissionOverwrite(send_messages=False, add_reactions=False)), (True, PermissionOverwrite(send_messages=True, add_reactions=True)), @@ -166,7 +167,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(await self.cog._silence(channel, True, None)) channel.set_permissions.assert_not_called() - async def test_silence_private_silenced_channel(self): + async def test_silenced_channel(self): """Channel had `send_message` and `add_reactions` permissions revoked for verified role.""" channel = MockTextChannel() overwrite = PermissionOverwrite(send_messages=True, add_reactions=None) @@ -180,8 +181,8 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): overwrite=overwrite ) - async def test_silence_private_preserves_other_overwrites(self): - """Channel's other unrelated overwrites were not changed when it was silenced.""" + async def test_preserved_other_overwrites(self): + """Channel's other unrelated overwrites were not changed.""" channel = MockTextChannel() overwrite = PermissionOverwrite(stream=True, attach_files=False) channel.overwrites_for.return_value = overwrite @@ -198,8 +199,8 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) - async def test_silence_private_notifier(self): - """Channel should be added to notifier with `persistent` set to `True`, and the other way around.""" + async def test_added_removed_notifier(self): + """Channel was added to notifier if `persistent` was `True`, and removed if `False`.""" channel = MockTextChannel() overwrite = PermissionOverwrite(send_messages=True, add_reactions=None) channel.overwrites_for.return_value = overwrite @@ -214,8 +215,8 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._silence(channel, False, None) self.cog.notifier.add_channel.assert_not_called() - async def test_silence_private_cached_perms(self): - """Channel's previous overwrites were cached when silenced.""" + async def test_cached_previous_overwrites(self): + """Channel's previous overwrites were cached.""" channel = MockTextChannel() overwrite = PermissionOverwrite(send_messages=True, add_reactions=None) overwrite_json = '{"send_messages": true, "add_reactions": null}' @@ -224,8 +225,47 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._silence(channel, False, None) self.cog.muted_channel_perms.set.assert_called_once_with(channel.id, overwrite_json) - async def test_unsilence_private_for_false(self): - """Permissions are not set and `False` is returned in an unsilenced channel.""" + +@autospec(Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) +class UnsilenceTests(unittest.IsolatedAsyncioTestCase): + """Tests for the unsilence command and its related helper methods.""" + + @autospec(Silence, "_reschedule", pass_mocks=False) + @autospec("bot.cogs.moderation.silence", "Scheduler", "SilenceNotifier", pass_mocks=False) + def setUp(self) -> None: + self.bot = MockBot() + self.cog = Silence(self.bot) + self.cog._init_task = mock.AsyncMock()() + + asyncio.run(self.cog._init_cog()) # Populate instance attributes. + + def unsilence_fixture(self) -> MockTextChannel: + """Setup mocks for a successful `_unsilence` call. Return the mocked channel.""" + overwrite_json = '{"send_messages": true, "add_reactions": null}' + self.cog.muted_channel_perms.get.return_value = overwrite_json + + # stream=True just to have at least one other overwrite not be the default value. + channel = MockTextChannel() + overwrite = PermissionOverwrite(stream=True, send_messages=False, add_reactions=False) + channel.overwrites_for.return_value = overwrite + + return channel + + async def test_sent_correct_message(self): + """Appropriate failure/success message was sent by the command.""" + test_cases = ( + (True, f"{Emojis.check_mark} unsilenced current channel."), + (False, f"{Emojis.cross_mark} current channel was not silenced.") + ) + for was_unsilenced, message in test_cases: + ctx = MockContext() + with self.subTest(was_unsilenced=was_unsilenced, message=message): + with mock.patch.object(self.cog, "_unsilence", return_value=was_unsilenced): + await self.cog.unsilence.callback(self.cog, ctx) + ctx.channel.send.assert_called_once_with(message) + + async def test_skipped_already_unsilenced(self): + """Permissions were not set and `False` was returned for an already unsilenced channel.""" self.cog.scheduler.__contains__.return_value = False self.cog.muted_channel_perms.get.return_value = None channel = MockTextChannel() @@ -233,9 +273,8 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(await self.cog._unsilence(channel)) channel.set_permissions.assert_not_called() - @mock.patch.object(Silence, "notifier", create=True) - async def test_unsilence_private_unsilenced_channel(self, _): - """Channel had `send_message` permissions restored.""" + async def test_unsilenced_channel(self): + """Channel's `send_message` and `add_reactions` overwrites were restored.""" channel = self.unsilence_fixture() overwrite = channel.overwrites_for.return_value @@ -248,23 +287,20 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.assertTrue(overwrite.send_messages) self.assertIsNone(overwrite.add_reactions) - @mock.patch.object(Silence, "notifier", create=True) - async def test_unsilence_private_removed_notifier(self, notifier): - """Channel was removed from `notifier` on unsilence.""" + async def test_removed_notifier(self): + """Channel was removed from `notifier`.""" channel = self.unsilence_fixture() await self.cog._unsilence(channel) - notifier.remove_channel.assert_called_once_with(channel) + self.cog.notifier.remove_channel.assert_called_once_with(channel) - @mock.patch.object(Silence, "notifier", create=True) - async def test_unsilence_private_removed_muted_channel(self, _): - """Channel was removed from overwrites cache on unsilence.""" + async def test_deleted_cached_overwrite(self): + """Channel was deleted from the overwrites cache.""" channel = self.unsilence_fixture() await self.cog._unsilence(channel) self.cog.muted_channel_perms.delete.assert_awaited_once_with(channel.id) - @mock.patch.object(Silence, "notifier", create=True) - async def test_unsilence_private_preserves_other_overwrites(self, _): - """Channel's other unrelated overwrites were not changed when it was unsilenced.""" + async def test_preserved_other_overwrites(self): + """Channel's other unrelated overwrites were not changed.""" channel = self.unsilence_fixture() overwrite = channel.overwrites_for.return_value @@ -279,15 +315,3 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): del new_overwrite_dict['add_reactions'] self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) - - def test_cog_unload_cancels_tasks(self): - """All scheduled tasks should be cancelled.""" - self.cog.cog_unload() - self.cog.scheduler.cancel_all.assert_called_once_with() - - @mock.patch("bot.cogs.moderation.silence.with_role_check") - @mock.patch("bot.cogs.moderation.silence.MODERATION_ROLES", new=(1, 2, 3)) - def test_cog_check(self, role_check): - """Role check is called with `MODERATION_ROLES`""" - self.cog.cog_check(self.ctx) - role_check.assert_called_once_with(self.ctx, *(1, 2, 3)) -- cgit v1.2.3 From 3f4d409bd951e749bdc643b0bc288cfc0f5136bd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 16:46:13 -0700 Subject: Silence tests: autospec _reschedule and SilenceNotifier for cog tests --- tests/bot/cogs/moderation/test_silence.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index f3ee184d4..ae8b59ff5 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -82,39 +82,45 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): self.bot = MockBot() self.cog = Silence(self.bot) + @autospec(Silence, "_reschedule", pass_mocks=False) + @autospec("bot.cogs.moderation.silence", "SilenceNotifier", pass_mocks=False) async def test_init_cog_got_guild(self): """Bot got guild after it became available.""" await self.cog._init_cog() self.bot.wait_until_guild_available.assert_awaited_once() self.bot.get_guild.assert_called_once_with(Guild.id) + @autospec(Silence, "_reschedule", pass_mocks=False) + @autospec("bot.cogs.moderation.silence", "SilenceNotifier", pass_mocks=False) async def test_init_cog_got_role(self): """Got `Roles.verified` role from guild.""" await self.cog._init_cog() guild = self.bot.get_guild() guild.get_role.assert_called_once_with(Roles.verified) + @autospec(Silence, "_reschedule", pass_mocks=False) + @autospec("bot.cogs.moderation.silence", "SilenceNotifier", pass_mocks=False) async def test_init_cog_got_channels(self): """Got channels from bot.""" await self.cog._init_cog() self.bot.get_channel.called_once_with(Channels.mod_alerts) self.bot.get_channel.called_once_with(Channels.mod_log) - @mock.patch("bot.cogs.moderation.silence.SilenceNotifier") + @autospec(Silence, "_reschedule", pass_mocks=False) + @autospec("bot.cogs.moderation.silence", "SilenceNotifier") async def test_init_cog_got_notifier(self, notifier): """Notifier was started with channel.""" mod_log = MockTextChannel() self.bot.get_channel.side_effect = (None, mod_log) await self.cog._init_cog() - notifier.assert_called_once_with(mod_log) - self.bot.get_channel.side_effect = None + notifier.assert_called_once_with(self.cog._mod_log_channel) def test_cog_unload_cancelled_tasks(self): """All scheduled tasks were cancelled.""" self.cog.cog_unload() self.cog.scheduler.cancel_all.assert_called_once_with() - @mock.patch("bot.cogs.moderation.silence.with_role_check") + @autospec("bot.cogs.moderation.silence", "with_role_check") @mock.patch("bot.cogs.moderation.silence.MODERATION_ROLES", new=(1, 2, 3)) def test_cog_check(self, role_check): """Role check was called with `MODERATION_ROLES`""" -- cgit v1.2.3 From a13bf79ba2aa672e52bcffe38720c7686cac67ba Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 18:40:36 -0700 Subject: Silence tests: merge unsilence fixture into setUp Now that there are separate test cases, there's no need to keep the fixtures separate. --- tests/bot/cogs/moderation/test_silence.py | 52 ++++++++++++------------------- 1 file changed, 20 insertions(+), 32 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index ae8b59ff5..fe6045c87 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -232,7 +232,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.cog.muted_channel_perms.set.assert_called_once_with(channel.id, overwrite_json) -@autospec(Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) +@autospec(Silence, "muted_channel_times", pass_mocks=False) class UnsilenceTests(unittest.IsolatedAsyncioTestCase): """Tests for the unsilence command and its related helper methods.""" @@ -243,19 +243,15 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): self.cog = Silence(self.bot) self.cog._init_task = mock.AsyncMock()() - asyncio.run(self.cog._init_cog()) # Populate instance attributes. - - def unsilence_fixture(self) -> MockTextChannel: - """Setup mocks for a successful `_unsilence` call. Return the mocked channel.""" - overwrite_json = '{"send_messages": true, "add_reactions": null}' - self.cog.muted_channel_perms.get.return_value = overwrite_json + perms_cache = mock.create_autospec(self.cog.muted_channel_perms, spec_set=True) + self.cog.muted_channel_perms = perms_cache - # stream=True just to have at least one other overwrite not be the default value. - channel = MockTextChannel() - overwrite = PermissionOverwrite(stream=True, send_messages=False, add_reactions=False) - channel.overwrites_for.return_value = overwrite + asyncio.run(self.cog._init_cog()) # Populate instance attributes. - return channel + perms_cache.get.return_value = '{"send_messages": true, "add_reactions": null}' + self.channel = MockTextChannel() + self.overwrite = PermissionOverwrite(stream=True, send_messages=False, add_reactions=False) + self.channel.overwrites_for.return_value = self.overwrite async def test_sent_correct_message(self): """Appropriate failure/success message was sent by the command.""" @@ -281,38 +277,30 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): async def test_unsilenced_channel(self): """Channel's `send_message` and `add_reactions` overwrites were restored.""" - channel = self.unsilence_fixture() - overwrite = channel.overwrites_for.return_value - - await self.cog._unsilence(channel) - channel.set_permissions.assert_awaited_once_with( - self.cog._verified_role, overwrite=overwrite + await self.cog._unsilence(self.channel) + self.channel.set_permissions.assert_awaited_once_with( + self.cog._verified_role, overwrite=self.overwrite ) # Recall that these values are determined by the fixture. - self.assertTrue(overwrite.send_messages) - self.assertIsNone(overwrite.add_reactions) + self.assertTrue(self.overwrite.send_messages) + self.assertIsNone(self.overwrite.add_reactions) async def test_removed_notifier(self): """Channel was removed from `notifier`.""" - channel = self.unsilence_fixture() - await self.cog._unsilence(channel) - self.cog.notifier.remove_channel.assert_called_once_with(channel) + await self.cog._unsilence(self.channel) + self.cog.notifier.remove_channel.assert_called_once_with(self.channel) async def test_deleted_cached_overwrite(self): """Channel was deleted from the overwrites cache.""" - channel = self.unsilence_fixture() - await self.cog._unsilence(channel) - self.cog.muted_channel_perms.delete.assert_awaited_once_with(channel.id) + await self.cog._unsilence(self.channel) + self.cog.muted_channel_perms.delete.assert_awaited_once_with(self.channel.id) async def test_preserved_other_overwrites(self): """Channel's other unrelated overwrites were not changed.""" - channel = self.unsilence_fixture() - overwrite = channel.overwrites_for.return_value - - prev_overwrite_dict = dict(overwrite) - await self.cog._unsilence(channel) - new_overwrite_dict = dict(overwrite) + prev_overwrite_dict = dict(self.overwrite) + await self.cog._unsilence(self.channel) + new_overwrite_dict = dict(self.overwrite) # Remove 'send_messages' & 'add_reactions' keys because they were changed by the method. del prev_overwrite_dict['send_messages'] -- cgit v1.2.3 From 8c2945f9fee9f6041edaea5b5c98209478b33259 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 18:46:27 -0700 Subject: Silence tests: create channel and overwrite in setUp for silence tests Reduce code redundancy by only defining them once. --- tests/bot/cogs/moderation/test_silence.py | 46 ++++++++++++------------------- 1 file changed, 17 insertions(+), 29 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index fe6045c87..eba8385bc 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -142,6 +142,10 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): asyncio.run(self.cog._init_cog()) # Populate instance attributes. + self.channel = MockTextChannel() + self.overwrite = PermissionOverwrite(stream=True, send_messages=True, add_reactions=False) + self.channel.overwrites_for.return_value = self.overwrite + async def test_sent_correct_message(self): """Appropriate failure/success message was sent by the command.""" test_cases = ( @@ -175,27 +179,19 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_silenced_channel(self): """Channel had `send_message` and `add_reactions` permissions revoked for verified role.""" - channel = MockTextChannel() - overwrite = PermissionOverwrite(send_messages=True, add_reactions=None) - channel.overwrites_for.return_value = overwrite - - self.assertTrue(await self.cog._silence(channel, False, None)) - self.assertFalse(overwrite.send_messages) - self.assertFalse(overwrite.add_reactions) - channel.set_permissions.assert_awaited_once_with( + self.assertTrue(await self.cog._silence(self.channel, False, None)) + self.assertFalse(self.overwrite.send_messages) + self.assertFalse(self.overwrite.add_reactions) + self.channel.set_permissions.assert_awaited_once_with( self.cog._verified_role, - overwrite=overwrite + overwrite=self.overwrite ) async def test_preserved_other_overwrites(self): """Channel's other unrelated overwrites were not changed.""" - channel = MockTextChannel() - overwrite = PermissionOverwrite(stream=True, attach_files=False) - channel.overwrites_for.return_value = overwrite - - prev_overwrite_dict = dict(overwrite) - await self.cog._silence(channel, False, None) - new_overwrite_dict = dict(overwrite) + prev_overwrite_dict = dict(self.overwrite) + await self.cog._silence(self.channel, False, None) + new_overwrite_dict = dict(self.overwrite) # Remove 'send_messages' & 'add_reactions' keys because they were changed by the method. del prev_overwrite_dict['send_messages'] @@ -207,29 +203,21 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_added_removed_notifier(self): """Channel was added to notifier if `persistent` was `True`, and removed if `False`.""" - channel = MockTextChannel() - overwrite = PermissionOverwrite(send_messages=True, add_reactions=None) - channel.overwrites_for.return_value = overwrite - with mock.patch.object(self.cog, "notifier", create=True): with self.subTest(persistent=True): - await self.cog._silence(channel, True, None) + await self.cog._silence(self.channel, True, None) self.cog.notifier.add_channel.assert_called_once() with mock.patch.object(self.cog, "notifier", create=True): with self.subTest(persistent=False): - await self.cog._silence(channel, False, None) + await self.cog._silence(self.channel, False, None) self.cog.notifier.add_channel.assert_not_called() async def test_cached_previous_overwrites(self): """Channel's previous overwrites were cached.""" - channel = MockTextChannel() - overwrite = PermissionOverwrite(send_messages=True, add_reactions=None) - overwrite_json = '{"send_messages": true, "add_reactions": null}' - channel.overwrites_for.return_value = overwrite - - await self.cog._silence(channel, False, None) - self.cog.muted_channel_perms.set.assert_called_once_with(channel.id, overwrite_json) + overwrite_json = '{"send_messages": true, "add_reactions": false}' + await self.cog._silence(self.channel, False, None) + self.cog.muted_channel_perms.set.assert_called_once_with(self.channel.id, overwrite_json) @autospec(Silence, "muted_channel_times", pass_mocks=False) -- cgit v1.2.3 From 282596d1414613e05ee8b956393913da976b35e3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 19:13:18 -0700 Subject: Silence tests: fix mock for _init_task An `AsyncMock` fails because it returns a coroutine which may only be awaited once. However, an `asyncio.Future` is perfect because it is easy to create and can be awaited repeatedly, just like the actual `asyncio.Task` that is being mocked. --- tests/bot/cogs/moderation/test_silence.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index eba8385bc..5d42d8c36 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -138,7 +138,8 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.bot = MockBot() self.cog = Silence(self.bot) - self.cog._init_task = mock.AsyncMock()() + self.cog._init_task = asyncio.Future() + self.cog._init_task.set_result(None) asyncio.run(self.cog._init_cog()) # Populate instance attributes. @@ -229,7 +230,8 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.bot = MockBot() self.cog = Silence(self.bot) - self.cog._init_task = mock.AsyncMock()() + self.cog._init_task = asyncio.Future() + self.cog._init_task.set_result(None) perms_cache = mock.create_autospec(self.cog.muted_channel_perms, spec_set=True) self.cog.muted_channel_perms = perms_cache -- cgit v1.2.3 From eda342b40ccd941050a1421ef1907cb2790c1cde Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 19:15:53 -0700 Subject: Silence tests: add a test for the time cache --- tests/bot/cogs/moderation/test_silence.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 5d42d8c36..1ae17177f 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -1,5 +1,6 @@ import asyncio import unittest +from datetime import datetime, timezone from unittest import mock from unittest.mock import Mock @@ -220,6 +221,20 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._silence(self.channel, False, None) self.cog.muted_channel_perms.set.assert_called_once_with(self.channel.id, overwrite_json) + @autospec("bot.cogs.moderation.silence", "datetime") + async def test_cached_unsilence_time(self, datetime_mock): + """The UTC POSIX timestamp for the unsilence was cached.""" + now_timestamp = 100 + duration = 15 + timestamp = now_timestamp + duration * 60 + datetime_mock.now.return_value = datetime.fromtimestamp(now_timestamp, tz=timezone.utc) + + ctx = MockContext(channel=self.channel) + await self.cog.silence.callback(self.cog, ctx, duration) + + self.cog.muted_channel_times.set.assert_awaited_once_with(ctx.channel.id, timestamp) + datetime_mock.now.assert_called_once_with(tz=timezone.utc) # Ensure it's using an aware dt. + @autospec(Silence, "muted_channel_times", pass_mocks=False) class UnsilenceTests(unittest.IsolatedAsyncioTestCase): -- cgit v1.2.3 From 5eec1c2db319ccdb1f71c1a25fa541eeb7a2707a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 19:17:51 -0700 Subject: Silence tests: add a test for caching permanent times --- tests/bot/cogs/moderation/test_silence.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 1ae17177f..2e756a88f 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -235,6 +235,12 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.cog.muted_channel_times.set.assert_awaited_once_with(ctx.channel.id, timestamp) datetime_mock.now.assert_called_once_with(tz=timezone.utc) # Ensure it's using an aware dt. + async def test_cached_indefinite_time(self): + """A value of -1 was cached for a permanent silence.""" + ctx = MockContext(channel=self.channel) + await self.cog.silence.callback(self.cog, ctx, None) + self.cog.muted_channel_times.set.assert_awaited_once_with(ctx.channel.id, -1) + @autospec(Silence, "muted_channel_times", pass_mocks=False) class UnsilenceTests(unittest.IsolatedAsyncioTestCase): -- cgit v1.2.3 From 1e1d358ae38bb9d554e993fb61ee8f0b52f977b5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 19:25:15 -0700 Subject: Silence tests: add tests for scheduling tasks --- tests/bot/cogs/moderation/test_silence.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 2e756a88f..979b4f4e5 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -241,6 +241,18 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog.silence.callback(self.cog, ctx, None) self.cog.muted_channel_times.set.assert_awaited_once_with(ctx.channel.id, -1) + async def test_scheduled_task(self): + """An unsilence task was scheduled.""" + ctx = MockContext(channel=self.channel) + await self.cog.silence.callback(self.cog, ctx) + self.cog.scheduler.schedule_later.assert_called_once() + + async def test_permanent_not_scheduled(self): + """A task was not scheduled for a permanent silence.""" + ctx = MockContext(channel=self.channel) + await self.cog.silence.callback(self.cog, ctx, None) + self.cog.scheduler.schedule_later.assert_not_called() + @autospec(Silence, "muted_channel_times", pass_mocks=False) class UnsilenceTests(unittest.IsolatedAsyncioTestCase): -- cgit v1.2.3 From d4fbd675d9803cc664909c19fcec8a430524f918 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 19:26:48 -0700 Subject: Silence tests: add a test for deletion from the time cache --- tests/bot/cogs/moderation/test_silence.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 979b4f4e5..6f913b8f9 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -319,6 +319,11 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._unsilence(self.channel) self.cog.muted_channel_perms.delete.assert_awaited_once_with(self.channel.id) + async def test_deleted_cached_time(self): + """Channel was deleted from the timestamp cache.""" + await self.cog._unsilence(self.channel) + self.cog.muted_channel_times.delete.assert_awaited_once_with(self.channel.id) + async def test_preserved_other_overwrites(self): """Channel's other unrelated overwrites were not changed.""" prev_overwrite_dict = dict(self.overwrite) -- cgit v1.2.3 From 67f88e0b63ec9ac198b8204d4b07e6f7ee67937b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 19:28:15 -0700 Subject: Silence tests: add a test for task cancellation --- tests/bot/cogs/moderation/test_silence.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 6f913b8f9..9e81df9c4 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -324,6 +324,11 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._unsilence(self.channel) self.cog.muted_channel_times.delete.assert_awaited_once_with(self.channel.id) + async def test_cancelled_task(self): + """The scheduled unsilence task should be cancelled.""" + await self.cog._unsilence(self.channel) + self.cog.scheduler.cancel.assert_called_once_with(self.channel.id) + async def test_preserved_other_overwrites(self): """Channel's other unrelated overwrites were not changed.""" prev_overwrite_dict = dict(self.overwrite) -- cgit v1.2.3 From cc956f24e1f748dbe97fc6bd96383d22a494c5ed Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 19:50:14 -0700 Subject: Silence tests: add a test for default overwrites on cache miss Use a False for `add_reactions` in the mock overwrite rather than None to be sure the default (also None) is actually set for it. Fix channels set by `_init_cog` not being mocked properly. --- tests/bot/cogs/moderation/test_silence.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 9e81df9c4..992906a50 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -261,7 +261,7 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): @autospec(Silence, "_reschedule", pass_mocks=False) @autospec("bot.cogs.moderation.silence", "Scheduler", "SilenceNotifier", pass_mocks=False) def setUp(self) -> None: - self.bot = MockBot() + self.bot = MockBot(get_channel=lambda _: MockTextChannel()) self.cog = Silence(self.bot) self.cog._init_task = asyncio.Future() self.cog._init_task.set_result(None) @@ -271,7 +271,8 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): asyncio.run(self.cog._init_cog()) # Populate instance attributes. - perms_cache.get.return_value = '{"send_messages": true, "add_reactions": null}' + self.cog.scheduler.__contains__.return_value = True + perms_cache.get.return_value = '{"send_messages": true, "add_reactions": false}' self.channel = MockTextChannel() self.overwrite = PermissionOverwrite(stream=True, send_messages=False, add_reactions=False) self.channel.overwrites_for.return_value = self.overwrite @@ -298,15 +299,29 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(await self.cog._unsilence(channel)) channel.set_permissions.assert_not_called() - async def test_unsilenced_channel(self): + async def test_restored_overwrites(self): """Channel's `send_message` and `add_reactions` overwrites were restored.""" await self.cog._unsilence(self.channel) self.channel.set_permissions.assert_awaited_once_with( - self.cog._verified_role, overwrite=self.overwrite + self.cog._verified_role, + overwrite=self.overwrite, ) # Recall that these values are determined by the fixture. self.assertTrue(self.overwrite.send_messages) + self.assertFalse(self.overwrite.add_reactions) + + async def test_cache_miss_used_default_overwrites(self): + """Both overwrites were set to None due previous values not being found in the cache.""" + self.cog.muted_channel_perms.get.return_value = None + + await self.cog._unsilence(self.channel) + self.channel.set_permissions.assert_awaited_once_with( + self.cog._verified_role, + overwrite=self.overwrite, + ) + + self.assertIsNone(self.overwrite.send_messages) self.assertIsNone(self.overwrite.add_reactions) async def test_removed_notifier(self): -- cgit v1.2.3 From e4548b2505cf4765cfd2a2c1a1762212cc5cba25 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 19:54:04 -0700 Subject: Silence tests: add a test for a mod alert on cache miss --- tests/bot/cogs/moderation/test_silence.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 992906a50..ccc908ee4 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -324,6 +324,13 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(self.overwrite.send_messages) self.assertIsNone(self.overwrite.add_reactions) + async def test_cache_miss_sent_mod_alert(self): + """A message was sent to the mod alerts channel.""" + self.cog.muted_channel_perms.get.return_value = None + + await self.cog._unsilence(self.channel) + self.cog._mod_alerts_channel.send.assert_awaited_once() + async def test_removed_notifier(self): """Channel was removed from `notifier`.""" await self.cog._unsilence(self.channel) -- cgit v1.2.3 From 33fb55cbe431211f99acfbce22129c48a60a1e6b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 19:58:26 -0700 Subject: Silence tests: also test that cache misses preserve other overwrites --- tests/bot/cogs/moderation/test_silence.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index ccc908ee4..71608d3f9 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -352,15 +352,19 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): self.cog.scheduler.cancel.assert_called_once_with(self.channel.id) async def test_preserved_other_overwrites(self): - """Channel's other unrelated overwrites were not changed.""" - prev_overwrite_dict = dict(self.overwrite) - await self.cog._unsilence(self.channel) - new_overwrite_dict = dict(self.overwrite) - - # Remove 'send_messages' & 'add_reactions' keys because they were changed by the method. - del prev_overwrite_dict['send_messages'] - del prev_overwrite_dict['add_reactions'] - del new_overwrite_dict['send_messages'] - del new_overwrite_dict['add_reactions'] - - self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) + """Channel's other unrelated overwrites were not changed, including cache misses.""" + for overwrite_json in ('{"send_messages": true, "add_reactions": null}', None): + with self.subTest(overwrite_json=overwrite_json): + self.cog.muted_channel_perms.get.return_value = overwrite_json + + prev_overwrite_dict = dict(self.overwrite) + await self.cog._unsilence(self.channel) + new_overwrite_dict = dict(self.overwrite) + + # Remove these keys because they were modified by the unsilence. + del prev_overwrite_dict['send_messages'] + del prev_overwrite_dict['add_reactions'] + del new_overwrite_dict['send_messages'] + del new_overwrite_dict['add_reactions'] + + self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) -- cgit v1.2.3 From 83d74c56af2a777a2a4f6f7f5347598d0000a66b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 20:14:46 -0700 Subject: Silence tests: assert against message constants Duplicating strings in assertions is redundant, closely coupled, and less maintainable. --- bot/cogs/moderation/silence.py | 26 +++++++++++++++++--------- tests/bot/cogs/moderation/test_silence.py | 13 +++++++------ 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index de799f64f..9732248ff 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -17,6 +17,17 @@ from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) +MSG_SILENCE_FAIL = f"{Emojis.cross_mark} current channel is already silenced." +MSG_SILENCE_PERMANENT = f"{Emojis.check_mark} silenced current channel indefinitely." +MSG_SILENCE_SUCCESS = Emojis.check_mark + " silenced current channel for {duration} minute(s)." + +MSG_UNSILENCE_FAIL = f"{Emojis.cross_mark} current channel was not silenced." +MSG_UNSILENCE_MANUAL = ( + f"{Emojis.cross_mark} current channel was not unsilenced because the current " + f"overwrites were set manually. Please edit them manually to unsilence." +) +MSG_UNSILENCE_SUCCESS = f"{Emojis.check_mark} unsilenced current channel." + class SilenceNotifier(tasks.Loop): """Loop notifier for posting notices to `alert_channel` containing added channels.""" @@ -96,15 +107,15 @@ class Silence(commands.Cog): log.debug(f"{ctx.author} is silencing channel #{ctx.channel}.") if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration): - await ctx.send(f"{Emojis.cross_mark} current channel is already silenced.") + await ctx.send(MSG_SILENCE_FAIL) return if duration is None: - await ctx.send(f"{Emojis.check_mark} silenced current channel indefinitely.") + await ctx.send(MSG_SILENCE_PERMANENT) await self.muted_channel_times.set(ctx.channel.id, -1) return - await ctx.send(f"{Emojis.check_mark} silenced current channel for {duration} minute(s).") + await ctx.send(MSG_SILENCE_SUCCESS.format(duration=duration)) self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence)) unsilence_time = (datetime.now(tz=timezone.utc) + timedelta(minutes=duration)) @@ -126,14 +137,11 @@ class Silence(commands.Cog): if not await self._unsilence(channel): overwrite = channel.overwrites_for(self._verified_role) if overwrite.send_messages is False and overwrite.add_reactions is False: - await channel.send( - f"{Emojis.cross_mark} current channel was not unsilenced because the current " - f"overwrites were set manually. Please edit them manually to unsilence." - ) + await channel.send(MSG_UNSILENCE_MANUAL) else: - await channel.send(f"{Emojis.cross_mark} current channel was not silenced.") + await channel.send(MSG_UNSILENCE_FAIL) else: - await channel.send(f"{Emojis.check_mark} unsilenced current channel.") + await channel.send(MSG_UNSILENCE_SUCCESS) async def _silence(self, channel: TextChannel, persistent: bool, duration: Optional[int]) -> bool: """ diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 71608d3f9..168794b6f 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -6,8 +6,9 @@ from unittest.mock import Mock from discord import PermissionOverwrite +from bot.cogs.moderation import silence from bot.cogs.moderation.silence import Silence, SilenceNotifier -from bot.constants import Channels, Emojis, Guild, Roles +from bot.constants import Channels, Guild, Roles from tests.helpers import MockBot, MockContext, MockTextChannel, autospec @@ -151,9 +152,9 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_sent_correct_message(self): """Appropriate failure/success message was sent by the command.""" test_cases = ( - (0.0001, f"{Emojis.check_mark} silenced current channel for 0.0001 minute(s).", True,), - (None, f"{Emojis.check_mark} silenced current channel indefinitely.", True,), - (5, f"{Emojis.cross_mark} current channel is already silenced.", False,), + (0.0001, silence.MSG_SILENCE_SUCCESS.format(duration=0.0001), True,), + (None, silence.MSG_SILENCE_PERMANENT, True,), + (5, silence.MSG_SILENCE_FAIL, False,), ) for duration, message, was_silenced in test_cases: ctx = MockContext() @@ -280,8 +281,8 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): async def test_sent_correct_message(self): """Appropriate failure/success message was sent by the command.""" test_cases = ( - (True, f"{Emojis.check_mark} unsilenced current channel."), - (False, f"{Emojis.cross_mark} current channel was not silenced.") + (True, silence.MSG_UNSILENCE_SUCCESS), + (False, silence.MSG_UNSILENCE_FAIL) ) for was_unsilenced, message in test_cases: ctx = MockContext() -- cgit v1.2.3 From ed30502710e805de5e3793b762a3848a0295582d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 20:18:14 -0700 Subject: Silence tests: add a subtest for the manual unsilence message --- tests/bot/cogs/moderation/test_silence.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 168794b6f..254480a6d 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -280,14 +280,17 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): async def test_sent_correct_message(self): """Appropriate failure/success message was sent by the command.""" + unsilenced_overwrite = PermissionOverwrite(send_messages=True, add_reactions=True) test_cases = ( - (True, silence.MSG_UNSILENCE_SUCCESS), - (False, silence.MSG_UNSILENCE_FAIL) + (True, silence.MSG_UNSILENCE_SUCCESS, unsilenced_overwrite), + (False, silence.MSG_UNSILENCE_FAIL, unsilenced_overwrite), + (False, silence.MSG_UNSILENCE_MANUAL, self.overwrite), ) - for was_unsilenced, message in test_cases: + for was_unsilenced, message, overwrite in test_cases: ctx = MockContext() - with self.subTest(was_unsilenced=was_unsilenced, message=message): + with self.subTest(was_unsilenced=was_unsilenced, message=message, overwrite=overwrite): with mock.patch.object(self.cog, "_unsilence", return_value=was_unsilenced): + ctx.channel.overwrites_for.return_value = overwrite await self.cog.unsilence.callback(self.cog, ctx) ctx.channel.send.assert_called_once_with(message) -- cgit v1.2.3 From f9d4081efc41c7ab9f5e6362a0ab4fab5bc88cd8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 20:23:15 -0700 Subject: Silence tests: access everything via the silence module The module is imported anyway to keep imports short and clean. Using it in patch targets is shorter and allows for the two imports from the module to be removed. --- tests/bot/cogs/moderation/test_silence.py | 47 +++++++++++++++---------------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 254480a6d..0a93cc623 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -7,7 +7,6 @@ from unittest.mock import Mock from discord import PermissionOverwrite from bot.cogs.moderation import silence -from bot.cogs.moderation.silence import Silence, SilenceNotifier from bot.constants import Channels, Guild, Roles from tests.helpers import MockBot, MockContext, MockTextChannel, autospec @@ -15,7 +14,7 @@ from tests.helpers import MockBot, MockContext, MockTextChannel, autospec class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.alert_channel = MockTextChannel() - self.notifier = SilenceNotifier(self.alert_channel) + self.notifier = silence.SilenceNotifier(self.alert_channel) self.notifier.stop = self.notifier_stop_mock = Mock() self.notifier.start = self.notifier_start_mock = Mock() @@ -75,41 +74,41 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): self.alert_channel.send.assert_not_called() -@autospec(Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) +@autospec(silence.Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) class SilenceCogTests(unittest.IsolatedAsyncioTestCase): """Tests for the general functionality of the Silence cog.""" - @autospec("bot.cogs.moderation.silence", "Scheduler", pass_mocks=False) + @autospec(silence, "Scheduler", pass_mocks=False) def setUp(self) -> None: self.bot = MockBot() - self.cog = Silence(self.bot) + self.cog = silence.Silence(self.bot) - @autospec(Silence, "_reschedule", pass_mocks=False) - @autospec("bot.cogs.moderation.silence", "SilenceNotifier", pass_mocks=False) + @autospec(silence.Silence, "_reschedule", pass_mocks=False) + @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_init_cog_got_guild(self): """Bot got guild after it became available.""" await self.cog._init_cog() self.bot.wait_until_guild_available.assert_awaited_once() self.bot.get_guild.assert_called_once_with(Guild.id) - @autospec(Silence, "_reschedule", pass_mocks=False) - @autospec("bot.cogs.moderation.silence", "SilenceNotifier", pass_mocks=False) + @autospec(silence.Silence, "_reschedule", pass_mocks=False) + @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_init_cog_got_role(self): """Got `Roles.verified` role from guild.""" await self.cog._init_cog() guild = self.bot.get_guild() guild.get_role.assert_called_once_with(Roles.verified) - @autospec(Silence, "_reschedule", pass_mocks=False) - @autospec("bot.cogs.moderation.silence", "SilenceNotifier", pass_mocks=False) + @autospec(silence.Silence, "_reschedule", pass_mocks=False) + @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_init_cog_got_channels(self): """Got channels from bot.""" await self.cog._init_cog() self.bot.get_channel.called_once_with(Channels.mod_alerts) self.bot.get_channel.called_once_with(Channels.mod_log) - @autospec(Silence, "_reschedule", pass_mocks=False) - @autospec("bot.cogs.moderation.silence", "SilenceNotifier") + @autospec(silence.Silence, "_reschedule", pass_mocks=False) + @autospec(silence, "SilenceNotifier") async def test_init_cog_got_notifier(self, notifier): """Notifier was started with channel.""" mod_log = MockTextChannel() @@ -122,8 +121,8 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): self.cog.cog_unload() self.cog.scheduler.cancel_all.assert_called_once_with() - @autospec("bot.cogs.moderation.silence", "with_role_check") - @mock.patch("bot.cogs.moderation.silence.MODERATION_ROLES", new=(1, 2, 3)) + @autospec(silence, "with_role_check") + @mock.patch.object(silence, "MODERATION_ROLES", new=(1, 2, 3)) def test_cog_check(self, role_check): """Role check was called with `MODERATION_ROLES`""" ctx = MockContext() @@ -131,15 +130,15 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): role_check.assert_called_once_with(ctx, *(1, 2, 3)) -@autospec(Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) +@autospec(silence.Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) class SilenceTests(unittest.IsolatedAsyncioTestCase): """Tests for the silence command and its related helper methods.""" - @autospec(Silence, "_reschedule", pass_mocks=False) - @autospec("bot.cogs.moderation.silence", "Scheduler", "SilenceNotifier", pass_mocks=False) + @autospec(silence.Silence, "_reschedule", pass_mocks=False) + @autospec(silence, "Scheduler", "SilenceNotifier", pass_mocks=False) def setUp(self) -> None: self.bot = MockBot() - self.cog = Silence(self.bot) + self.cog = silence.Silence(self.bot) self.cog._init_task = asyncio.Future() self.cog._init_task.set_result(None) @@ -222,7 +221,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._silence(self.channel, False, None) self.cog.muted_channel_perms.set.assert_called_once_with(self.channel.id, overwrite_json) - @autospec("bot.cogs.moderation.silence", "datetime") + @autospec(silence, "datetime") async def test_cached_unsilence_time(self, datetime_mock): """The UTC POSIX timestamp for the unsilence was cached.""" now_timestamp = 100 @@ -255,15 +254,15 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.cog.scheduler.schedule_later.assert_not_called() -@autospec(Silence, "muted_channel_times", pass_mocks=False) +@autospec(silence.Silence, "muted_channel_times", pass_mocks=False) class UnsilenceTests(unittest.IsolatedAsyncioTestCase): """Tests for the unsilence command and its related helper methods.""" - @autospec(Silence, "_reschedule", pass_mocks=False) - @autospec("bot.cogs.moderation.silence", "Scheduler", "SilenceNotifier", pass_mocks=False) + @autospec(silence.Silence, "_reschedule", pass_mocks=False) + @autospec(silence, "Scheduler", "SilenceNotifier", pass_mocks=False) def setUp(self) -> None: self.bot = MockBot(get_channel=lambda _: MockTextChannel()) - self.cog = Silence(self.bot) + self.cog = silence.Silence(self.bot) self.cog._init_task = asyncio.Future() self.cog._init_task.set_result(None) -- cgit v1.2.3 From cbdc14a3abbcbee644e9d4a6f3ffde125a7c91f1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 20:36:48 -0700 Subject: Silence tests: remove _reschedule patch for cog tests They don't do anything because they patch the class rather than the instance. It's too late for patching the instance to work since the `setUp` fixture, which instantiates the cog, executes before the patches do. Patching `setUp` would work (and its done in the other test cases), but some tests in this case will need the unpatched function too. Patching it doesn't serve much benefit to most tests anyway, so it's not worth the effort trying to make them work where they aren't needed. --- tests/bot/cogs/moderation/test_silence.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 0a93cc623..667d61776 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -83,7 +83,6 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): self.bot = MockBot() self.cog = silence.Silence(self.bot) - @autospec(silence.Silence, "_reschedule", pass_mocks=False) @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_init_cog_got_guild(self): """Bot got guild after it became available.""" @@ -91,7 +90,6 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): self.bot.wait_until_guild_available.assert_awaited_once() self.bot.get_guild.assert_called_once_with(Guild.id) - @autospec(silence.Silence, "_reschedule", pass_mocks=False) @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_init_cog_got_role(self): """Got `Roles.verified` role from guild.""" @@ -99,7 +97,6 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): guild = self.bot.get_guild() guild.get_role.assert_called_once_with(Roles.verified) - @autospec(silence.Silence, "_reschedule", pass_mocks=False) @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_init_cog_got_channels(self): """Got channels from bot.""" @@ -107,7 +104,6 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): self.bot.get_channel.called_once_with(Channels.mod_alerts) self.bot.get_channel.called_once_with(Channels.mod_log) - @autospec(silence.Silence, "_reschedule", pass_mocks=False) @autospec(silence, "SilenceNotifier") async def test_init_cog_got_notifier(self, notifier): """Notifier was started with channel.""" -- cgit v1.2.3 From 366f975bbb22c45b9e071644ab4053416bf351fb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 20:37:24 -0700 Subject: Silence tests: add a test for _init_cog rescheduling unsilences --- tests/bot/cogs/moderation/test_silence.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 667d61776..5deed2d0b 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -112,6 +112,13 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): await self.cog._init_cog() notifier.assert_called_once_with(self.cog._mod_log_channel) + @autospec(silence, "SilenceNotifier", pass_mocks=False) + async def test_init_cog_rescheduled(self): + """`_reschedule_` coroutine was awaited.""" + self.cog._reschedule = mock.create_autospec(self.cog._reschedule, spec_set=True) + await self.cog._init_cog() + self.cog._reschedule.assert_awaited_once_with() + def test_cog_unload_cancelled_tasks(self): """All scheduled tasks were cancelled.""" self.cog.cog_unload() -- cgit v1.2.3 From d5032459bfe1bbbc10ffc3b95809e0fc377de60c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 18 Aug 2020 10:19:17 -0700 Subject: Silence tests: test the scheduler skips missing channels --- tests/bot/cogs/moderation/test_silence.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 5deed2d0b..2c8059752 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -133,6 +133,31 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): role_check.assert_called_once_with(ctx, *(1, 2, 3)) +@autospec(silence.Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) +class RescheduleTests(unittest.IsolatedAsyncioTestCase): + """Tests for the rescheduling of cached unsilences.""" + + @autospec(silence, "Scheduler", "SilenceNotifier", pass_mocks=False) + def setUp(self): + self.bot = MockBot() + self.cog = silence.Silence(self.bot) + self.cog._unsilence_wrapper = mock.create_autospec(self.cog._unsilence_wrapper, spec_set=True) + + with mock.patch.object(self.cog, "_reschedule", spec_set=True, autospec=True): + asyncio.run(self.cog._init_cog()) # Populate instance attributes. + + async def test_skipped_missing_channel(self): + """Did nothing because the channel couldn't be retrieved.""" + self.cog.muted_channel_times.items.return_value = [(123, -1), (123, 1), (123, 100000000000)] + self.bot.get_channel.return_value = None + + await self.cog._reschedule() + + self.cog.notifier.add_channel.assert_not_called() + self.cog._unsilence_wrapper.assert_not_called() + self.cog.scheduler.schedule_later.assert_not_called() + + @autospec(silence.Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) class SilenceTests(unittest.IsolatedAsyncioTestCase): """Tests for the silence command and its related helper methods.""" -- cgit v1.2.3 From d44e3f795f8c56a1fbbce2833b27474b263e911b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 18 Aug 2020 10:53:33 -0700 Subject: Silence tests: test the rescheduler adds permanent silence to notifier --- tests/bot/cogs/moderation/test_silence.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 2c8059752..6e8c9ff38 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -157,6 +157,20 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): self.cog._unsilence_wrapper.assert_not_called() self.cog.scheduler.schedule_later.assert_not_called() + async def test_added_permanent_to_notifier(self): + """Permanently silenced channels were added to the notifier.""" + channels = [MockTextChannel(id=123), MockTextChannel(id=456)] + self.bot.get_channel.side_effect = channels + self.cog.muted_channel_times.items.return_value = [(123, -1), (456, -1)] + + await self.cog._reschedule() + + self.cog.notifier.add_channel.assert_any_call(channels[0]) + self.cog.notifier.add_channel.assert_any_call(channels[1]) + + self.cog._unsilence_wrapper.assert_not_called() + self.cog.scheduler.schedule_later.assert_not_called() + @autospec(silence.Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) class SilenceTests(unittest.IsolatedAsyncioTestCase): -- cgit v1.2.3 From 5f23f6630cd1c44d129d23e4becd9fce7f76135d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 18 Aug 2020 10:57:17 -0700 Subject: Silence tests: test the rescheduler unsilences expired silences --- tests/bot/cogs/moderation/test_silence.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 6e8c9ff38..d9ff13595 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -171,6 +171,20 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): self.cog._unsilence_wrapper.assert_not_called() self.cog.scheduler.schedule_later.assert_not_called() + async def test_unsilenced_expired(self): + """Unsilenced expired silences.""" + channels = [MockTextChannel(id=123), MockTextChannel(id=456)] + self.bot.get_channel.side_effect = channels + self.cog.muted_channel_times.items.return_value = [(123, 100), (456, 200)] + + await self.cog._reschedule() + + self.cog._unsilence_wrapper.assert_any_call(channels[0]) + self.cog._unsilence_wrapper.assert_any_call(channels[1]) + + self.cog.notifier.add_channel.assert_not_called() + self.cog.scheduler.schedule_later.assert_not_called() + @autospec(silence.Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) class SilenceTests(unittest.IsolatedAsyncioTestCase): -- cgit v1.2.3 From 7fadf2d531562dcc7e78bcb70d59d0a0575a18be Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 18 Aug 2020 11:56:49 -0700 Subject: Silence tests: add a test for rescheduling active silences --- tests/bot/cogs/moderation/test_silence.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index d9ff13595..3d111341b 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -11,6 +11,13 @@ from bot.constants import Channels, Guild, Roles from tests.helpers import MockBot, MockContext, MockTextChannel, autospec +# Have to subclass it because builtins can't be patched. +class PatchedDatetime(datetime): + """A datetime object with a mocked now() function.""" + + now = mock.create_autospec(datetime, "now") + + class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.alert_channel = MockTextChannel() @@ -185,6 +192,28 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): self.cog.notifier.add_channel.assert_not_called() self.cog.scheduler.schedule_later.assert_not_called() + @mock.patch.object(silence, "datetime", new=PatchedDatetime) + async def test_rescheduled_active(self): + """Rescheduled active silences.""" + channels = [MockTextChannel(id=123), MockTextChannel(id=456)] + self.bot.get_channel.side_effect = channels + self.cog.muted_channel_times.items.return_value = [(123, 2000), (456, 3000)] + silence.datetime.now.return_value = datetime.fromtimestamp(1000, tz=timezone.utc) + + self.cog._unsilence_wrapper = mock.MagicMock() + unsilence_return = self.cog._unsilence_wrapper.return_value + + await self.cog._reschedule() + + # Yuck. + calls = [mock.call(1000, 123, unsilence_return), mock.call(2000, 456, unsilence_return)] + self.cog.scheduler.schedule_later.assert_has_calls(calls) + + unsilence_calls = [mock.call(channel) for channel in channels] + self.cog._unsilence_wrapper.assert_has_calls(unsilence_calls) + + self.cog.notifier.add_channel.assert_not_called() + @autospec(silence.Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) class SilenceTests(unittest.IsolatedAsyncioTestCase): -- cgit v1.2.3 From a9ddbf346d95e67731196d8d822835330b6992af Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 18 Aug 2020 12:02:29 -0700 Subject: Silence tests: more accurately assert the silence cmd schedule a task --- tests/bot/cogs/moderation/test_silence.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 3d111341b..bc41422ef 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -328,9 +328,13 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_scheduled_task(self): """An unsilence task was scheduled.""" - ctx = MockContext(channel=self.channel) - await self.cog.silence.callback(self.cog, ctx) - self.cog.scheduler.schedule_later.assert_called_once() + ctx = MockContext(channel=self.channel, invoke=mock.MagicMock()) + + await self.cog.silence.callback(self.cog, ctx, 5) + + args = (300, ctx.channel.id, ctx.invoke.return_value) + self.cog.scheduler.schedule_later.assert_called_once_with(*args) + ctx.invoke.assert_called_once_with(self.cog.unsilence) async def test_permanent_not_scheduled(self): """A task was not scheduled for a permanent silence.""" -- cgit v1.2.3 From 40c6f688eb0e317b1489b069f263f25b202a345c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 18 Aug 2020 12:15:36 -0700 Subject: Silence tests: remove unnecessary spec_set args It's not really necessary to set to True when mocking functions. --- tests/bot/cogs/moderation/test_silence.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index bc41422ef..5c6d677ca 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -122,7 +122,7 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_init_cog_rescheduled(self): """`_reschedule_` coroutine was awaited.""" - self.cog._reschedule = mock.create_autospec(self.cog._reschedule, spec_set=True) + self.cog._reschedule = mock.create_autospec(self.cog._reschedule) await self.cog._init_cog() self.cog._reschedule.assert_awaited_once_with() @@ -148,9 +148,9 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): def setUp(self): self.bot = MockBot() self.cog = silence.Silence(self.bot) - self.cog._unsilence_wrapper = mock.create_autospec(self.cog._unsilence_wrapper, spec_set=True) + self.cog._unsilence_wrapper = mock.create_autospec(self.cog._unsilence_wrapper) - with mock.patch.object(self.cog, "_reschedule", spec_set=True, autospec=True): + with mock.patch.object(self.cog, "_reschedule", autospec=True): asyncio.run(self.cog._init_cog()) # Populate instance attributes. async def test_skipped_missing_channel(self): -- cgit v1.2.3 From 27a00bf29193b1768c298c1455936bb7dc92aaf1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 18 Aug 2020 12:18:14 -0700 Subject: Silence: rename caches --- bot/cogs/moderation/silence.py | 18 +++++++------- tests/bot/cogs/moderation/test_silence.py | 40 +++++++++++++++---------------- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 9732248ff..5851be00a 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -72,11 +72,11 @@ class Silence(commands.Cog): # Maps muted channel IDs to their previous overwrites for send_message and add_reactions. # Overwrites are stored as JSON. - muted_channel_perms = RedisCache() + previous_overwrites = RedisCache() # Maps muted channel IDs to POSIX timestamps of when they'll be unsilenced. # A timestamp equal to -1 means it's indefinite. - muted_channel_times = RedisCache() + unsilence_timestamps = RedisCache() def __init__(self, bot: Bot): self.bot = bot @@ -112,14 +112,14 @@ class Silence(commands.Cog): if duration is None: await ctx.send(MSG_SILENCE_PERMANENT) - await self.muted_channel_times.set(ctx.channel.id, -1) + await self.unsilence_timestamps.set(ctx.channel.id, -1) return await ctx.send(MSG_SILENCE_SUCCESS.format(duration=duration)) self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence)) unsilence_time = (datetime.now(tz=timezone.utc) + timedelta(minutes=duration)) - await self.muted_channel_times.set(ctx.channel.id, unsilence_time.timestamp()) + await self.unsilence_timestamps.set(ctx.channel.id, unsilence_time.timestamp()) @commands.command(aliases=("unhush",)) async def unsilence(self, ctx: Context) -> None: @@ -160,7 +160,7 @@ class Silence(commands.Cog): overwrite.update(send_messages=False, add_reactions=False) await channel.set_permissions(self._verified_role, overwrite=overwrite) - await self.muted_channel_perms.set(channel.id, json.dumps(prev_overwrites)) + await self.previous_overwrites.set(channel.id, json.dumps(prev_overwrites)) if persistent: log.info(f"Silenced #{channel} ({channel.id}) indefinitely.") @@ -180,7 +180,7 @@ class Silence(commands.Cog): Return `True` if channel permissions were changed, `False` otherwise. """ - prev_overwrites = await self.muted_channel_perms.get(channel.id) + prev_overwrites = await self.previous_overwrites.get(channel.id) if channel.id not in self.scheduler and prev_overwrites is None: log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") return False @@ -197,8 +197,8 @@ class Silence(commands.Cog): self.scheduler.cancel(channel.id) self.notifier.remove_channel(channel) - await self.muted_channel_perms.delete(channel.id) - await self.muted_channel_times.delete(channel.id) + await self.previous_overwrites.delete(channel.id) + await self.unsilence_timestamps.delete(channel.id) if prev_overwrites is None: await self._mod_alerts_channel.send( @@ -211,7 +211,7 @@ class Silence(commands.Cog): async def _reschedule(self) -> None: """Reschedule unsilencing of active silences and add permanent ones to the notifier.""" - for channel_id, timestamp in await self.muted_channel_times.items(): + for channel_id, timestamp in await self.unsilence_timestamps.items(): channel = self.bot.get_channel(channel_id) if channel is None: log.info(f"Can't reschedule silence for {channel_id}: channel not found.") diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 5c6d677ca..a66d27d08 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -81,7 +81,7 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): self.alert_channel.send.assert_not_called() -@autospec(silence.Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) +@autospec(silence.Silence, "previous_overwrites", "unsilence_timestamps", pass_mocks=False) class SilenceCogTests(unittest.IsolatedAsyncioTestCase): """Tests for the general functionality of the Silence cog.""" @@ -140,7 +140,7 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): role_check.assert_called_once_with(ctx, *(1, 2, 3)) -@autospec(silence.Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) +@autospec(silence.Silence, "previous_overwrites", "unsilence_timestamps", pass_mocks=False) class RescheduleTests(unittest.IsolatedAsyncioTestCase): """Tests for the rescheduling of cached unsilences.""" @@ -155,7 +155,7 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): async def test_skipped_missing_channel(self): """Did nothing because the channel couldn't be retrieved.""" - self.cog.muted_channel_times.items.return_value = [(123, -1), (123, 1), (123, 100000000000)] + self.cog.unsilence_timestamps.items.return_value = [(123, -1), (123, 1), (123, 100000000000)] self.bot.get_channel.return_value = None await self.cog._reschedule() @@ -168,7 +168,7 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): """Permanently silenced channels were added to the notifier.""" channels = [MockTextChannel(id=123), MockTextChannel(id=456)] self.bot.get_channel.side_effect = channels - self.cog.muted_channel_times.items.return_value = [(123, -1), (456, -1)] + self.cog.unsilence_timestamps.items.return_value = [(123, -1), (456, -1)] await self.cog._reschedule() @@ -182,7 +182,7 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): """Unsilenced expired silences.""" channels = [MockTextChannel(id=123), MockTextChannel(id=456)] self.bot.get_channel.side_effect = channels - self.cog.muted_channel_times.items.return_value = [(123, 100), (456, 200)] + self.cog.unsilence_timestamps.items.return_value = [(123, 100), (456, 200)] await self.cog._reschedule() @@ -197,7 +197,7 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): """Rescheduled active silences.""" channels = [MockTextChannel(id=123), MockTextChannel(id=456)] self.bot.get_channel.side_effect = channels - self.cog.muted_channel_times.items.return_value = [(123, 2000), (456, 3000)] + self.cog.unsilence_timestamps.items.return_value = [(123, 2000), (456, 3000)] silence.datetime.now.return_value = datetime.fromtimestamp(1000, tz=timezone.utc) self.cog._unsilence_wrapper = mock.MagicMock() @@ -215,7 +215,7 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): self.cog.notifier.add_channel.assert_not_called() -@autospec(silence.Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) +@autospec(silence.Silence, "previous_overwrites", "unsilence_timestamps", pass_mocks=False) class SilenceTests(unittest.IsolatedAsyncioTestCase): """Tests for the silence command and its related helper methods.""" @@ -304,7 +304,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): """Channel's previous overwrites were cached.""" overwrite_json = '{"send_messages": true, "add_reactions": false}' await self.cog._silence(self.channel, False, None) - self.cog.muted_channel_perms.set.assert_called_once_with(self.channel.id, overwrite_json) + self.cog.previous_overwrites.set.assert_called_once_with(self.channel.id, overwrite_json) @autospec(silence, "datetime") async def test_cached_unsilence_time(self, datetime_mock): @@ -317,14 +317,14 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): ctx = MockContext(channel=self.channel) await self.cog.silence.callback(self.cog, ctx, duration) - self.cog.muted_channel_times.set.assert_awaited_once_with(ctx.channel.id, timestamp) + self.cog.unsilence_timestamps.set.assert_awaited_once_with(ctx.channel.id, timestamp) datetime_mock.now.assert_called_once_with(tz=timezone.utc) # Ensure it's using an aware dt. async def test_cached_indefinite_time(self): """A value of -1 was cached for a permanent silence.""" ctx = MockContext(channel=self.channel) await self.cog.silence.callback(self.cog, ctx, None) - self.cog.muted_channel_times.set.assert_awaited_once_with(ctx.channel.id, -1) + self.cog.unsilence_timestamps.set.assert_awaited_once_with(ctx.channel.id, -1) async def test_scheduled_task(self): """An unsilence task was scheduled.""" @@ -343,7 +343,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.cog.scheduler.schedule_later.assert_not_called() -@autospec(silence.Silence, "muted_channel_times", pass_mocks=False) +@autospec(silence.Silence, "unsilence_timestamps", pass_mocks=False) class UnsilenceTests(unittest.IsolatedAsyncioTestCase): """Tests for the unsilence command and its related helper methods.""" @@ -355,13 +355,13 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): self.cog._init_task = asyncio.Future() self.cog._init_task.set_result(None) - perms_cache = mock.create_autospec(self.cog.muted_channel_perms, spec_set=True) - self.cog.muted_channel_perms = perms_cache + overwrites_cache = mock.create_autospec(self.cog.previous_overwrites, spec_set=True) + self.cog.previous_overwrites = overwrites_cache asyncio.run(self.cog._init_cog()) # Populate instance attributes. self.cog.scheduler.__contains__.return_value = True - perms_cache.get.return_value = '{"send_messages": true, "add_reactions": false}' + overwrites_cache.get.return_value = '{"send_messages": true, "add_reactions": false}' self.channel = MockTextChannel() self.overwrite = PermissionOverwrite(stream=True, send_messages=False, add_reactions=False) self.channel.overwrites_for.return_value = self.overwrite @@ -385,7 +385,7 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): async def test_skipped_already_unsilenced(self): """Permissions were not set and `False` was returned for an already unsilenced channel.""" self.cog.scheduler.__contains__.return_value = False - self.cog.muted_channel_perms.get.return_value = None + self.cog.previous_overwrites.get.return_value = None channel = MockTextChannel() self.assertFalse(await self.cog._unsilence(channel)) @@ -405,7 +405,7 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): async def test_cache_miss_used_default_overwrites(self): """Both overwrites were set to None due previous values not being found in the cache.""" - self.cog.muted_channel_perms.get.return_value = None + self.cog.previous_overwrites.get.return_value = None await self.cog._unsilence(self.channel) self.channel.set_permissions.assert_awaited_once_with( @@ -418,7 +418,7 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): async def test_cache_miss_sent_mod_alert(self): """A message was sent to the mod alerts channel.""" - self.cog.muted_channel_perms.get.return_value = None + self.cog.previous_overwrites.get.return_value = None await self.cog._unsilence(self.channel) self.cog._mod_alerts_channel.send.assert_awaited_once() @@ -431,12 +431,12 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): async def test_deleted_cached_overwrite(self): """Channel was deleted from the overwrites cache.""" await self.cog._unsilence(self.channel) - self.cog.muted_channel_perms.delete.assert_awaited_once_with(self.channel.id) + self.cog.previous_overwrites.delete.assert_awaited_once_with(self.channel.id) async def test_deleted_cached_time(self): """Channel was deleted from the timestamp cache.""" await self.cog._unsilence(self.channel) - self.cog.muted_channel_times.delete.assert_awaited_once_with(self.channel.id) + self.cog.unsilence_timestamps.delete.assert_awaited_once_with(self.channel.id) async def test_cancelled_task(self): """The scheduled unsilence task should be cancelled.""" @@ -447,7 +447,7 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): """Channel's other unrelated overwrites were not changed, including cache misses.""" for overwrite_json in ('{"send_messages": true, "add_reactions": null}', None): with self.subTest(overwrite_json=overwrite_json): - self.cog.muted_channel_perms.get.return_value = overwrite_json + self.cog.previous_overwrites.get.return_value = overwrite_json prev_overwrite_dict = dict(self.overwrite) await self.cog._unsilence(self.channel) -- cgit v1.2.3 From 2fd2c77035e87dde009c39aa7345e4871d5b41df Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 18 Aug 2020 15:02:13 -0700 Subject: Silence: cancel init task when cog unloads --- bot/cogs/moderation/silence.py | 9 +++++++-- tests/bot/cogs/moderation/test_silence.py | 7 +++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 5851be00a..c339fd4d0 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -231,8 +231,13 @@ class Silence(commands.Cog): self.scheduler.schedule_later(delta, channel_id, self._unsilence_wrapper(channel)) def cog_unload(self) -> None: - """Cancel scheduled tasks.""" - self.scheduler.cancel_all() + """Cancel the init task and scheduled tasks.""" + # It's important to wait for _init_task (specifically for _reschedule) to be cancelled + # before cancelling scheduled tasks. Otherwise, it's possible for _reschedule to schedule + # more tasks after cancel_all has finished, despite _init_task.cancel being called first. + # This is cause cancel() on its own doesn't block until the task is cancelled. + self._init_task.cancel() + self._init_task.add_done_callback(lambda _: self.scheduler.cancel_all) # This cannot be static (must have a __func__ attribute). def cog_check(self, ctx: Context) -> bool: diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index a66d27d08..d56a731b6 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -127,9 +127,12 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): self.cog._reschedule.assert_awaited_once_with() def test_cog_unload_cancelled_tasks(self): - """All scheduled tasks were cancelled.""" + """The init task was cancelled.""" + self.cog._init_task = asyncio.Future() self.cog.cog_unload() - self.cog.scheduler.cancel_all.assert_called_once_with() + + # It's too annoying to test cancel_all since it's a done callback and wrapped in a lambda. + self.assertTrue(self.cog._init_task.cancelled()) @autospec(silence, "with_role_check") @mock.patch.object(silence, "MODERATION_ROLES", new=(1, 2, 3)) -- cgit v1.2.3 From ff91b76348d95e308589ac131898aba9f7cca986 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 19 Aug 2020 20:06:23 +0200 Subject: Verification: add missing word to task status message --- bot/cogs/verification.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 14c0abfda..08d54d575 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -537,9 +537,9 @@ class Verification(Cog): mention = f"<@&{constants.Roles.unverified}>" if self.ping_unverified.is_running(): - ping_status = f"{constants.Emojis.incident_actioned} Ping {mention} is running." + ping_status = f"{constants.Emojis.incident_actioned} Ping {mention} task is running." else: - ping_status = f"{constants.Emojis.incident_unactioned} Ping {mention} is **not** running." + ping_status = f"{constants.Emojis.incident_unactioned} Ping {mention} task is **not** running." embed = discord.Embed( title="Verification system", -- cgit v1.2.3 From a66df2917b08f91f5e3decc87ef6ca9b55dd66a4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 19 Aug 2020 11:58:51 -0700 Subject: Add comment to explain why import is deferred --- bot/exts/backend/sync/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/backend/sync/__init__.py b/bot/exts/backend/sync/__init__.py index 2541beaa8..e628b958b 100644 --- a/bot/exts/backend/sync/__init__.py +++ b/bot/exts/backend/sync/__init__.py @@ -3,5 +3,6 @@ from bot.bot import Bot def setup(bot: Bot) -> None: """Load the Sync cog.""" + # Defer import to reduce side effects from importing the sync package. from ._cog import Sync bot.add_cog(Sync(bot)) -- cgit v1.2.3 From f150e698781b013fb67169806699c40a7b62dc24 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 19 Aug 2020 12:10:08 -0700 Subject: Replace relative imports with absolute ones PEP 8 recommends absolute imports over relative ones. --- bot/exts/backend/sync/__init__.py | 2 +- bot/exts/backend/sync/_cog.py | 2 +- bot/exts/moderation/infraction/_scheduler.py | 4 ++-- bot/exts/moderation/infraction/infractions.py | 6 +++--- bot/exts/moderation/infraction/management.py | 4 ++-- bot/exts/moderation/infraction/superstarify.py | 4 ++-- bot/exts/moderation/watchchannels/bigbrother.py | 2 +- bot/exts/moderation/watchchannels/talentpool.py | 2 +- 8 files changed, 13 insertions(+), 13 deletions(-) diff --git a/bot/exts/backend/sync/__init__.py b/bot/exts/backend/sync/__init__.py index e628b958b..829098f79 100644 --- a/bot/exts/backend/sync/__init__.py +++ b/bot/exts/backend/sync/__init__.py @@ -4,5 +4,5 @@ from bot.bot import Bot def setup(bot: Bot) -> None: """Load the Sync cog.""" # Defer import to reduce side effects from importing the sync package. - from ._cog import Sync + from bot.exts.backend.sync._cog import Sync bot.add_cog(Sync(bot)) diff --git a/bot/exts/backend/sync/_cog.py b/bot/exts/backend/sync/_cog.py index b6068f328..6e85e2b7d 100644 --- a/bot/exts/backend/sync/_cog.py +++ b/bot/exts/backend/sync/_cog.py @@ -8,7 +8,7 @@ from discord.ext.commands import Cog, Context from bot import constants from bot.api import ResponseCodeError from bot.bot import Bot -from . import _syncers +from bot.exts.backend.sync import _syncers log = logging.getLogger(__name__) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 1310fd3d9..da0babcfc 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -13,11 +13,11 @@ from bot import constants from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Colours, STAFF_CHANNELS +from bot.exts.moderation.infraction import _utils +from bot.exts.moderation.infraction._utils import UserSnowflake from bot.exts.moderation.modlog import ModLog from bot.utils import time from bot.utils.scheduling import Scheduler -from . import _utils -from ._utils import UserSnowflake log = logging.getLogger(__name__) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index cb459b447..84ea47371 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -12,10 +12,10 @@ from bot.bot import Bot from bot.constants import Event from bot.converters import Expiry, FetchedMember from bot.decorators import respect_role_hierarchy +from bot.exts.moderation.infraction import _utils +from bot.exts.moderation.infraction._scheduler import InfractionScheduler +from bot.exts.moderation.infraction._utils import UserSnowflake from bot.utils.checks import with_role_check -from . import _utils -from ._scheduler import InfractionScheduler -from ._utils import UserSnowflake log = logging.getLogger(__name__) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index eea6ac9ea..5875abd26 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -10,12 +10,12 @@ from discord.ext.commands import Context from bot import constants from bot.bot import Bot from bot.converters import Expiry, InfractionSearchQuery, allowed_strings, proxy_user +from bot.exts.moderation.infraction import _utils +from bot.exts.moderation.infraction.infractions import Infractions from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator from bot.utils import time from bot.utils.checks import in_whitelist_check, with_role_check -from . import _utils -from .infractions import Infractions log = logging.getLogger(__name__) diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index 7dc5b4691..a4e78c4d3 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -11,10 +11,10 @@ from discord.ext.commands import Cog, Context, command from bot import constants from bot.bot import Bot from bot.converters import Expiry +from bot.exts.moderation.infraction import _utils +from bot.exts.moderation.infraction._scheduler import InfractionScheduler from bot.utils.checks import with_role_check from bot.utils.time import format_infraction -from . import _utils -from ._scheduler import InfractionScheduler log = logging.getLogger(__name__) NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#nickname-policy" diff --git a/bot/exts/moderation/watchchannels/bigbrother.py b/bot/exts/moderation/watchchannels/bigbrother.py index 4ac916c9e..bfba19820 100644 --- a/bot/exts/moderation/watchchannels/bigbrother.py +++ b/bot/exts/moderation/watchchannels/bigbrother.py @@ -9,7 +9,7 @@ from bot.constants import Channels, MODERATION_ROLES, Webhooks from bot.converters import FetchedMember from bot.decorators import with_role from bot.exts.moderation.infraction._utils import post_infraction -from ._watchchannel import WatchChannel +from bot.exts.moderation.watchchannels._watchchannel import WatchChannel log = logging.getLogger(__name__) diff --git a/bot/exts/moderation/watchchannels/talentpool.py b/bot/exts/moderation/watchchannels/talentpool.py index 2972f56e1..f65f9d664 100644 --- a/bot/exts/moderation/watchchannels/talentpool.py +++ b/bot/exts/moderation/watchchannels/talentpool.py @@ -10,9 +10,9 @@ from bot.bot import Bot from bot.constants import Channels, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks from bot.converters import FetchedMember from bot.decorators import with_role +from bot.exts.moderation.watchchannels._watchchannel import WatchChannel from bot.pagination import LinePaginator from bot.utils import time -from ._watchchannel import WatchChannel log = logging.getLogger(__name__) -- cgit v1.2.3 From 57f5f1089bed9971e79458ea0d2a40be90d7d3e5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 19 Aug 2020 12:17:22 -0700 Subject: Extensions: beautify name unqualification Yes, that's a real word. --- bot/exts/utils/extensions.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/bot/exts/utils/extensions.py b/bot/exts/utils/extensions.py index 671397650..7ad6b1fdd 100644 --- a/bot/exts/utils/extensions.py +++ b/bot/exts/utils/extensions.py @@ -19,6 +19,11 @@ from bot.utils.checks import with_role_check log = logging.getLogger(__name__) +def unqualify(name: str) -> str: + """Return an unqualified name given a qualified module/package `name`.""" + return name.rsplit(".", maxsplit=1)[-1] + + def walk_extensions() -> t.Iterator[str]: """Yield extension names from the bot.exts subpackage.""" @@ -26,7 +31,7 @@ def walk_extensions() -> t.Iterator[str]: raise ImportError(name=name) # pragma: no cover for module in pkgutil.walk_packages(exts.__path__, f"{exts.__name__}.", onerror=on_error): - if module.name.rsplit(".", maxsplit=1)[-1].startswith("_"): + if unqualify(module.name).startswith("_"): # Ignore module/package names starting with an underscore. continue @@ -75,8 +80,7 @@ class Extension(commands.Converter): matches = [] for ext in EXTENSIONS: - name = ext.rsplit(".", maxsplit=1)[-1] - if argument == name: + if argument == unqualify(ext): matches.append(ext) if len(matches) > 1: -- cgit v1.2.3 From 87f2bddfd5da0f2787e1dbd898bb497c1ec6c0d0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 19 Aug 2020 12:52:35 -0700 Subject: Extensions: move utility functions to a utility module Makes the cog cleaner and makes the functions more accessible for other modules. --- bot/exts/utils/extensions.py | 30 +----------------------------- bot/utils/extensions.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 29 deletions(-) create mode 100644 bot/utils/extensions.py diff --git a/bot/exts/utils/extensions.py b/bot/exts/utils/extensions.py index 7ad6b1fdd..65b5c3630 100644 --- a/bot/exts/utils/extensions.py +++ b/bot/exts/utils/extensions.py @@ -1,8 +1,5 @@ import functools -import importlib -import inspect import logging -import pkgutil import typing as t from enum import Enum @@ -15,37 +12,12 @@ from bot.bot import Bot from bot.constants import Emojis, MODERATION_ROLES, Roles, URLs from bot.pagination import LinePaginator from bot.utils.checks import with_role_check +from bot.utils.extensions import EXTENSIONS, unqualify log = logging.getLogger(__name__) -def unqualify(name: str) -> str: - """Return an unqualified name given a qualified module/package `name`.""" - return name.rsplit(".", maxsplit=1)[-1] - - -def walk_extensions() -> t.Iterator[str]: - """Yield extension names from the bot.exts subpackage.""" - - def on_error(name: str) -> t.NoReturn: - raise ImportError(name=name) # pragma: no cover - - for module in pkgutil.walk_packages(exts.__path__, f"{exts.__name__}.", onerror=on_error): - if unqualify(module.name).startswith("_"): - # Ignore module/package names starting with an underscore. - continue - - if module.ispkg: - imported = importlib.import_module(module.name) - if not inspect.isfunction(getattr(imported, "setup", None)): - # If it lacks a setup function, it's not an extension. - continue - - yield module.name - - UNLOAD_BLACKLIST = {f"{exts.__name__}.utils.extensions", f"{exts.__name__}.moderation.modlog"} -EXTENSIONS = frozenset(walk_extensions()) BASE_PATH_LEN = len(exts.__name__.split(".")) diff --git a/bot/utils/extensions.py b/bot/utils/extensions.py new file mode 100644 index 000000000..50350ea8d --- /dev/null +++ b/bot/utils/extensions.py @@ -0,0 +1,34 @@ +import importlib +import inspect +import pkgutil +from typing import Iterator, NoReturn + +from bot import exts + + +def unqualify(name: str) -> str: + """Return an unqualified name given a qualified module/package `name`.""" + return name.rsplit(".", maxsplit=1)[-1] + + +def walk_extensions() -> Iterator[str]: + """Yield extension names from the bot.exts subpackage.""" + + def on_error(name: str) -> NoReturn: + raise ImportError(name=name) # pragma: no cover + + for module in pkgutil.walk_packages(exts.__path__, f"{exts.__name__}.", onerror=on_error): + if unqualify(module.name).startswith("_"): + # Ignore module/package names starting with an underscore. + continue + + if module.ispkg: + imported = importlib.import_module(module.name) + if not inspect.isfunction(getattr(imported, "setup", None)): + # If it lacks a setup function, it's not an extension. + continue + + yield module.name + + +EXTENSIONS = frozenset(walk_extensions()) -- cgit v1.2.3 From 3c2d654ecd8872ec6eee8efd8ef8d90313f3b30e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 19 Aug 2020 13:00:35 -0700 Subject: Dynamically discover and load extensions upon startup Being explicit is nice, but the list of extensions to load has gotten quite long. It's a bit of an eyesore. It's still fairly easy to temporarily exclude extensions: just remove them from the set. Granted, being able to comment them out was more convenient. --- bot/__main__.py | 70 ++++++++------------------------------------------------- 1 file changed, 9 insertions(+), 61 deletions(-) diff --git a/bot/__main__.py b/bot/__main__.py index 555847357..8770ac31b 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -9,7 +9,9 @@ from sentry_sdk.integrations.redis import RedisIntegration from bot import constants, patches from bot.bot import Bot +from bot.utils.extensions import EXTENSIONS +# Set up Sentry. sentry_logging = LoggingIntegration( level=logging.DEBUG, event_level=logging.WARNING @@ -24,6 +26,7 @@ sentry_sdk.init( ] ) +# Instantiate the bot. allowed_roles = [discord.Object(id_) for id_ in constants.MODERATION_ROLES] bot = Bot( command_prefix=when_mentioned_or(constants.Bot.prefix), @@ -33,68 +36,13 @@ bot = Bot( allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) ) -# Backend -bot.load_extension("bot.exts.backend.config_verifier") -bot.load_extension("bot.exts.backend.error_handler") -bot.load_extension("bot.exts.backend.logging") -bot.load_extension("bot.exts.backend.sync") +# Load extensions. +extensions = set(EXTENSIONS) # Create a mutable copy. +if not constants.HelpChannels.enable: + extensions.remove("bot.exts.help_channels") -# Filters -bot.load_extension("bot.exts.filters.antimalware") -bot.load_extension("bot.exts.filters.antispam") -bot.load_extension("bot.exts.filters.filter_lists") -bot.load_extension("bot.exts.filters.filtering") -bot.load_extension("bot.exts.filters.security") -bot.load_extension("bot.exts.filters.token_remover") -bot.load_extension("bot.exts.filters.webhook_remover") - -# Info -bot.load_extension("bot.exts.info.doc") -bot.load_extension("bot.exts.info.help") -bot.load_extension("bot.exts.info.information") -bot.load_extension("bot.exts.info.python_news") -bot.load_extension("bot.exts.info.reddit") -bot.load_extension("bot.exts.info.site") -bot.load_extension("bot.exts.info.source") -bot.load_extension("bot.exts.info.stats") -bot.load_extension("bot.exts.info.tags") -bot.load_extension("bot.exts.info.wolfram") - -# Moderation -bot.load_extension("bot.exts.moderation.defcon") -bot.load_extension("bot.exts.moderation.incidents") -bot.load_extension("bot.exts.moderation.modlog") -bot.load_extension("bot.exts.moderation.silence") -bot.load_extension("bot.exts.moderation.slowmode") -bot.load_extension("bot.exts.moderation.verification") - -# Moderation - Infraction -bot.load_extension("bot.exts.moderation.infraction.infractions") -bot.load_extension("bot.exts.moderation.infraction.management") -bot.load_extension("bot.exts.moderation.infraction.superstarify") - -# Moderation - Watchchannels -bot.load_extension("bot.exts.moderation.watchchannels.bigbrother") -bot.load_extension("bot.exts.moderation.watchchannels.talentpool") - -# Utils -bot.load_extension("bot.exts.utils.bot") -bot.load_extension("bot.exts.utils.clean") -bot.load_extension("bot.exts.utils.eval") -bot.load_extension("bot.exts.utils.extensions") -bot.load_extension("bot.exts.utils.jams") -bot.load_extension("bot.exts.utils.reminders") -bot.load_extension("bot.exts.utils.snekbox") -bot.load_extension("bot.exts.utils.utils") - -# Misc -bot.load_extension("bot.exts.alias") -bot.load_extension("bot.exts.dm_relay") -bot.load_extension("bot.exts.duck_pond") -bot.load_extension("bot.exts.off_topic_names") - -if constants.HelpChannels.enable: - bot.load_extension("bot.exts.help_channels") +for extension in extensions: + bot.load_extension(extension) # Apply `message_edited_at` patch if discord.py did not yet release a bug fix. if not hasattr(discord.message.Message, '_handle_edited_timestamp'): -- cgit v1.2.3 From 07084103cabb95f1af25890e0059a93244088010 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 19 Aug 2020 13:34:34 -0700 Subject: Categorise most of the uncategorised extensions --- bot/exts/alias.py | 153 ---------- bot/exts/backend/alias.py | 153 ++++++++++ bot/exts/dm_relay.py | 124 -------- bot/exts/duck_pond.py | 166 ----------- bot/exts/fun/__init__.py | 0 bot/exts/fun/duck_pond.py | 166 +++++++++++ bot/exts/fun/off_topic_names.py | 162 +++++++++++ bot/exts/moderation/dm_relay.py | 124 ++++++++ bot/exts/off_topic_names.py | 162 ----------- tests/bot/exts/fun/__init__.py | 0 tests/bot/exts/fun/test_duck_pond.py | 548 +++++++++++++++++++++++++++++++++++ tests/bot/exts/test_duck_pond.py | 548 ----------------------------------- 12 files changed, 1153 insertions(+), 1153 deletions(-) delete mode 100644 bot/exts/alias.py create mode 100644 bot/exts/backend/alias.py delete mode 100644 bot/exts/dm_relay.py delete mode 100644 bot/exts/duck_pond.py create mode 100644 bot/exts/fun/__init__.py create mode 100644 bot/exts/fun/duck_pond.py create mode 100644 bot/exts/fun/off_topic_names.py create mode 100644 bot/exts/moderation/dm_relay.py delete mode 100644 bot/exts/off_topic_names.py create mode 100644 tests/bot/exts/fun/__init__.py create mode 100644 tests/bot/exts/fun/test_duck_pond.py delete mode 100644 tests/bot/exts/test_duck_pond.py diff --git a/bot/exts/alias.py b/bot/exts/alias.py deleted file mode 100644 index 77867b933..000000000 --- a/bot/exts/alias.py +++ /dev/null @@ -1,153 +0,0 @@ -import inspect -import logging - -from discord import Colour, Embed -from discord.ext.commands import ( - Cog, Command, Context, Greedy, - clean_content, command, group, -) - -from bot.bot import Bot -from bot.converters import FetchedMember, TagNameConverter -from bot.exts.utils.extensions import Extension -from bot.pagination import LinePaginator - -log = logging.getLogger(__name__) - - -class Alias (Cog): - """Aliases for commonly used commands.""" - - def __init__(self, bot: Bot): - self.bot = bot - - async def invoke(self, ctx: Context, cmd_name: str, *args, **kwargs) -> None: - """Invokes a command with args and kwargs.""" - log.debug(f"{cmd_name} was invoked through an alias") - cmd = self.bot.get_command(cmd_name) - if not cmd: - return log.info(f'Did not find command "{cmd_name}" to invoke.') - elif not await cmd.can_run(ctx): - return log.info( - f'{str(ctx.author)} tried to run the command "{cmd_name}" but lacks permission.' - ) - - await ctx.invoke(cmd, *args, **kwargs) - - @command(name='aliases') - async def aliases_command(self, ctx: Context) -> None: - """Show configured aliases on the bot.""" - embed = Embed( - title='Configured aliases', - colour=Colour.blue() - ) - await LinePaginator.paginate( - ( - f"• `{ctx.prefix}{value.name}` " - f"=> `{ctx.prefix}{name[:-len('_alias')].replace('_', ' ')}`" - for name, value in inspect.getmembers(self) - if isinstance(value, Command) and name.endswith('_alias') - ), - ctx, embed, empty=False, max_lines=20 - ) - - @command(name="resources", aliases=("resource",), hidden=True) - async def site_resources_alias(self, ctx: Context) -> None: - """Alias for invoking site resources.""" - await self.invoke(ctx, "site resources") - - @command(name="tools", hidden=True) - async def site_tools_alias(self, ctx: Context) -> None: - """Alias for invoking site tools.""" - await self.invoke(ctx, "site tools") - - @command(name="watch", hidden=True) - async def bigbrother_watch_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """Alias for invoking bigbrother watch [user] [reason].""" - await self.invoke(ctx, "bigbrother watch", user, reason=reason) - - @command(name="unwatch", hidden=True) - async def bigbrother_unwatch_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """Alias for invoking bigbrother unwatch [user] [reason].""" - await self.invoke(ctx, "bigbrother unwatch", user, reason=reason) - - @command(name="home", hidden=True) - async def site_home_alias(self, ctx: Context) -> None: - """Alias for invoking site home.""" - await self.invoke(ctx, "site home") - - @command(name="faq", hidden=True) - async def site_faq_alias(self, ctx: Context) -> None: - """Alias for invoking site faq.""" - await self.invoke(ctx, "site faq") - - @command(name="rules", aliases=("rule",), hidden=True) - async def site_rules_alias(self, ctx: Context, rules: Greedy[int], *_: str) -> None: - """Alias for invoking site rules.""" - await self.invoke(ctx, "site rules", *rules) - - @command(name="reload", hidden=True) - async def extensions_reload_alias(self, ctx: Context, *extensions: Extension) -> None: - """Alias for invoking extensions reload [extensions...].""" - await self.invoke(ctx, "extensions reload", *extensions) - - @command(name="defon", hidden=True) - async def defcon_enable_alias(self, ctx: Context) -> None: - """Alias for invoking defcon enable.""" - await self.invoke(ctx, "defcon enable") - - @command(name="defoff", hidden=True) - async def defcon_disable_alias(self, ctx: Context) -> None: - """Alias for invoking defcon disable.""" - await self.invoke(ctx, "defcon disable") - - @command(name="exception", hidden=True) - async def tags_get_traceback_alias(self, ctx: Context) -> None: - """Alias for invoking tags get traceback.""" - await self.invoke(ctx, "tags get", tag_name="traceback") - - @group(name="get", - aliases=("show", "g"), - hidden=True, - invoke_without_command=True) - async def get_group_alias(self, ctx: Context) -> None: - """Group for reverse aliases for commands like `tags get`, allowing for `get tags` or `get docs`.""" - pass - - @get_group_alias.command(name="tags", aliases=("tag", "t"), hidden=True) - async def tags_get_alias( - self, ctx: Context, *, tag_name: TagNameConverter = None - ) -> None: - """ - Alias for invoking tags get [tag_name]. - - tag_name: str - tag to be viewed. - """ - await self.invoke(ctx, "tags get", tag_name=tag_name) - - @get_group_alias.command(name="docs", aliases=("doc", "d"), hidden=True) - async def docs_get_alias( - self, ctx: Context, symbol: clean_content = None - ) -> None: - """Alias for invoking docs get [symbol].""" - await self.invoke(ctx, "docs get", symbol) - - @command(name="nominate", hidden=True) - async def nomination_add_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """Alias for invoking talentpool add [user] [reason].""" - await self.invoke(ctx, "talentpool add", user, reason=reason) - - @command(name="unnominate", hidden=True) - async def nomination_end_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """Alias for invoking nomination end [user] [reason].""" - await self.invoke(ctx, "nomination end", user, reason=reason) - - @command(name="nominees", hidden=True) - async def nominees_alias(self, ctx: Context) -> None: - """Alias for invoking tp watched.""" - await self.invoke(ctx, "talentpool watched") - - -def setup(bot: Bot) -> None: - """Load the Alias cog.""" - bot.add_cog(Alias(bot)) diff --git a/bot/exts/backend/alias.py b/bot/exts/backend/alias.py new file mode 100644 index 000000000..77867b933 --- /dev/null +++ b/bot/exts/backend/alias.py @@ -0,0 +1,153 @@ +import inspect +import logging + +from discord import Colour, Embed +from discord.ext.commands import ( + Cog, Command, Context, Greedy, + clean_content, command, group, +) + +from bot.bot import Bot +from bot.converters import FetchedMember, TagNameConverter +from bot.exts.utils.extensions import Extension +from bot.pagination import LinePaginator + +log = logging.getLogger(__name__) + + +class Alias (Cog): + """Aliases for commonly used commands.""" + + def __init__(self, bot: Bot): + self.bot = bot + + async def invoke(self, ctx: Context, cmd_name: str, *args, **kwargs) -> None: + """Invokes a command with args and kwargs.""" + log.debug(f"{cmd_name} was invoked through an alias") + cmd = self.bot.get_command(cmd_name) + if not cmd: + return log.info(f'Did not find command "{cmd_name}" to invoke.') + elif not await cmd.can_run(ctx): + return log.info( + f'{str(ctx.author)} tried to run the command "{cmd_name}" but lacks permission.' + ) + + await ctx.invoke(cmd, *args, **kwargs) + + @command(name='aliases') + async def aliases_command(self, ctx: Context) -> None: + """Show configured aliases on the bot.""" + embed = Embed( + title='Configured aliases', + colour=Colour.blue() + ) + await LinePaginator.paginate( + ( + f"• `{ctx.prefix}{value.name}` " + f"=> `{ctx.prefix}{name[:-len('_alias')].replace('_', ' ')}`" + for name, value in inspect.getmembers(self) + if isinstance(value, Command) and name.endswith('_alias') + ), + ctx, embed, empty=False, max_lines=20 + ) + + @command(name="resources", aliases=("resource",), hidden=True) + async def site_resources_alias(self, ctx: Context) -> None: + """Alias for invoking site resources.""" + await self.invoke(ctx, "site resources") + + @command(name="tools", hidden=True) + async def site_tools_alias(self, ctx: Context) -> None: + """Alias for invoking site tools.""" + await self.invoke(ctx, "site tools") + + @command(name="watch", hidden=True) + async def bigbrother_watch_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + """Alias for invoking bigbrother watch [user] [reason].""" + await self.invoke(ctx, "bigbrother watch", user, reason=reason) + + @command(name="unwatch", hidden=True) + async def bigbrother_unwatch_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + """Alias for invoking bigbrother unwatch [user] [reason].""" + await self.invoke(ctx, "bigbrother unwatch", user, reason=reason) + + @command(name="home", hidden=True) + async def site_home_alias(self, ctx: Context) -> None: + """Alias for invoking site home.""" + await self.invoke(ctx, "site home") + + @command(name="faq", hidden=True) + async def site_faq_alias(self, ctx: Context) -> None: + """Alias for invoking site faq.""" + await self.invoke(ctx, "site faq") + + @command(name="rules", aliases=("rule",), hidden=True) + async def site_rules_alias(self, ctx: Context, rules: Greedy[int], *_: str) -> None: + """Alias for invoking site rules.""" + await self.invoke(ctx, "site rules", *rules) + + @command(name="reload", hidden=True) + async def extensions_reload_alias(self, ctx: Context, *extensions: Extension) -> None: + """Alias for invoking extensions reload [extensions...].""" + await self.invoke(ctx, "extensions reload", *extensions) + + @command(name="defon", hidden=True) + async def defcon_enable_alias(self, ctx: Context) -> None: + """Alias for invoking defcon enable.""" + await self.invoke(ctx, "defcon enable") + + @command(name="defoff", hidden=True) + async def defcon_disable_alias(self, ctx: Context) -> None: + """Alias for invoking defcon disable.""" + await self.invoke(ctx, "defcon disable") + + @command(name="exception", hidden=True) + async def tags_get_traceback_alias(self, ctx: Context) -> None: + """Alias for invoking tags get traceback.""" + await self.invoke(ctx, "tags get", tag_name="traceback") + + @group(name="get", + aliases=("show", "g"), + hidden=True, + invoke_without_command=True) + async def get_group_alias(self, ctx: Context) -> None: + """Group for reverse aliases for commands like `tags get`, allowing for `get tags` or `get docs`.""" + pass + + @get_group_alias.command(name="tags", aliases=("tag", "t"), hidden=True) + async def tags_get_alias( + self, ctx: Context, *, tag_name: TagNameConverter = None + ) -> None: + """ + Alias for invoking tags get [tag_name]. + + tag_name: str - tag to be viewed. + """ + await self.invoke(ctx, "tags get", tag_name=tag_name) + + @get_group_alias.command(name="docs", aliases=("doc", "d"), hidden=True) + async def docs_get_alias( + self, ctx: Context, symbol: clean_content = None + ) -> None: + """Alias for invoking docs get [symbol].""" + await self.invoke(ctx, "docs get", symbol) + + @command(name="nominate", hidden=True) + async def nomination_add_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + """Alias for invoking talentpool add [user] [reason].""" + await self.invoke(ctx, "talentpool add", user, reason=reason) + + @command(name="unnominate", hidden=True) + async def nomination_end_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + """Alias for invoking nomination end [user] [reason].""" + await self.invoke(ctx, "nomination end", user, reason=reason) + + @command(name="nominees", hidden=True) + async def nominees_alias(self, ctx: Context) -> None: + """Alias for invoking tp watched.""" + await self.invoke(ctx, "talentpool watched") + + +def setup(bot: Bot) -> None: + """Load the Alias cog.""" + bot.add_cog(Alias(bot)) diff --git a/bot/exts/dm_relay.py b/bot/exts/dm_relay.py deleted file mode 100644 index 0d8f340b4..000000000 --- a/bot/exts/dm_relay.py +++ /dev/null @@ -1,124 +0,0 @@ -import logging -from typing import Optional - -import discord -from discord import Color -from discord.ext import commands -from discord.ext.commands import Cog - -from bot import constants -from bot.bot import Bot -from bot.converters import UserMentionOrID -from bot.utils import RedisCache -from bot.utils.checks import in_whitelist_check, with_role_check -from bot.utils.messages import send_attachments -from bot.utils.webhooks import send_webhook - -log = logging.getLogger(__name__) - - -class DMRelay(Cog): - """Relay direct messages to and from the bot.""" - - # RedisCache[str, t.Union[discord.User.id, discord.Member.id]] - dm_cache = RedisCache() - - def __init__(self, bot: Bot): - self.bot = bot - self.webhook_id = constants.Webhooks.dm_log - self.webhook = None - self.bot.loop.create_task(self.fetch_webhook()) - - @commands.command(aliases=("reply",)) - async def send_dm(self, ctx: commands.Context, member: Optional[UserMentionOrID], *, message: str) -> None: - """ - Allows you to send a DM to a user from the bot. - - If `member` is not provided, it will send to the last user who DM'd the bot. - - This feature should be used extremely sparingly. Use ModMail if you need to have a serious - conversation with a user. This is just for responding to extraordinary DMs, having a little - fun with users, and telling people they are DMing the wrong bot. - - NOTE: This feature will be removed if it is overused. - """ - if not member: - user_id = await self.dm_cache.get("last_user") - member = ctx.guild.get_member(user_id) if user_id else None - - # If we still don't have a Member at this point, give up - if not member: - log.debug("This bot has never gotten a DM, or the RedisCache has been cleared.") - await ctx.message.add_reaction("❌") - return - - try: - await member.send(message) - except discord.errors.Forbidden: - log.debug("User has disabled DMs.") - await ctx.message.add_reaction("❌") - else: - await ctx.message.add_reaction("✅") - self.bot.stats.incr("dm_relay.dm_sent") - - async def fetch_webhook(self) -> None: - """Fetches the webhook object, so we can post to it.""" - await self.bot.wait_until_guild_available() - - try: - self.webhook = await self.bot.fetch_webhook(self.webhook_id) - except discord.HTTPException: - log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") - - @Cog.listener() - async def on_message(self, message: discord.Message) -> None: - """Relays the message's content and attachments to the dm_log channel.""" - # Only relay DMs from humans - if message.author.bot or message.guild or self.webhook is None: - return - - if message.clean_content: - await send_webhook( - webhook=self.webhook, - content=message.clean_content, - username=f"{message.author.display_name} ({message.author.id})", - avatar_url=message.author.avatar_url - ) - await self.dm_cache.set("last_user", message.author.id) - self.bot.stats.incr("dm_relay.dm_received") - - # Handle any attachments - if message.attachments: - try: - await send_attachments(message, self.webhook) - except (discord.errors.Forbidden, discord.errors.NotFound): - e = discord.Embed( - description=":x: **This message contained an attachment, but it could not be retrieved**", - color=Color.red() - ) - await send_webhook( - webhook=self.webhook, - embed=e, - username=f"{message.author.display_name} ({message.author.id})", - avatar_url=message.author.avatar_url - ) - except discord.HTTPException: - log.exception("Failed to send an attachment to the webhook") - - def cog_check(self, ctx: commands.Context) -> bool: - """Only allow moderators to invoke the commands in this cog.""" - checks = [ - with_role_check(ctx, *constants.MODERATION_ROLES), - in_whitelist_check( - ctx, - channels=[constants.Channels.dm_log], - redirect=None, - fail_silently=True, - ) - ] - return all(checks) - - -def setup(bot: Bot) -> None: - """Load the DMRelay cog.""" - bot.add_cog(DMRelay(bot)) diff --git a/bot/exts/duck_pond.py b/bot/exts/duck_pond.py deleted file mode 100644 index 7021069fa..000000000 --- a/bot/exts/duck_pond.py +++ /dev/null @@ -1,166 +0,0 @@ -import logging -from typing import Union - -import discord -from discord import Color, Embed, Member, Message, RawReactionActionEvent, User, errors -from discord.ext.commands import Cog - -from bot import constants -from bot.bot import Bot -from bot.utils.messages import send_attachments -from bot.utils.webhooks import send_webhook - -log = logging.getLogger(__name__) - - -class DuckPond(Cog): - """Relays messages to #duck-pond whenever a certain number of duck reactions have been achieved.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.webhook_id = constants.Webhooks.duck_pond - self.webhook = None - self.bot.loop.create_task(self.fetch_webhook()) - - async def fetch_webhook(self) -> None: - """Fetches the webhook object, so we can post to it.""" - await self.bot.wait_until_guild_available() - - try: - self.webhook = await self.bot.fetch_webhook(self.webhook_id) - except discord.HTTPException: - log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") - - @staticmethod - def is_staff(member: Union[User, Member]) -> bool: - """Check if a specific member or user is staff.""" - if hasattr(member, "roles"): - for role in member.roles: - if role.id in constants.STAFF_ROLES: - return True - return False - - async def has_green_checkmark(self, message: Message) -> bool: - """Check if the message has a green checkmark reaction.""" - for reaction in message.reactions: - if reaction.emoji == "✅": - async for user in reaction.users(): - if user == self.bot.user: - return True - return False - - async def count_ducks(self, message: Message) -> int: - """ - Count the number of ducks in the reactions of a specific message. - - Only counts ducks added by staff members. - """ - duck_count = 0 - duck_reactors = [] - - for reaction in message.reactions: - async for user in reaction.users(): - - # Is the user a staff member and not already counted as reactor? - if not self.is_staff(user) or user.id in duck_reactors: - continue - - # Is the emoji a duck? - if hasattr(reaction.emoji, "id"): - if reaction.emoji.id in constants.DuckPond.custom_emojis: - duck_count += 1 - duck_reactors.append(user.id) - elif isinstance(reaction.emoji, str): - if reaction.emoji == "🦆": - duck_count += 1 - duck_reactors.append(user.id) - return duck_count - - async def relay_message(self, message: Message) -> None: - """Relays the message's content and attachments to the duck pond channel.""" - if message.clean_content: - await send_webhook( - webhook=self.webhook, - content=message.clean_content, - username=message.author.display_name, - avatar_url=message.author.avatar_url - ) - - if message.attachments: - try: - await send_attachments(message, self.webhook) - except (errors.Forbidden, errors.NotFound): - e = Embed( - description=":x: **This message contained an attachment, but it could not be retrieved**", - color=Color.red() - ) - await send_webhook( - webhook=self.webhook, - embed=e, - username=message.author.display_name, - avatar_url=message.author.avatar_url - ) - except discord.HTTPException: - log.exception("Failed to send an attachment to the webhook") - - await message.add_reaction("✅") - - @staticmethod - def _payload_has_duckpond_emoji(payload: RawReactionActionEvent) -> bool: - """Test if the RawReactionActionEvent payload contains a duckpond emoji.""" - if payload.emoji.is_custom_emoji(): - if payload.emoji.id in constants.DuckPond.custom_emojis: - return True - elif payload.emoji.name == "🦆": - return True - - return False - - @Cog.listener() - async def on_raw_reaction_add(self, payload: RawReactionActionEvent) -> None: - """ - Determine if a message should be sent to the duck pond. - - This will count the number of duck reactions on the message, and if this amount meets the - amount of ducks specified in the config under duck_pond/threshold, it will - send the message off to the duck pond. - """ - # Is the emoji in the reaction a duck? - if not self._payload_has_duckpond_emoji(payload): - return - - channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id) - message = await channel.fetch_message(payload.message_id) - member = discord.utils.get(message.guild.members, id=payload.user_id) - - # Is the member a human and a staff member? - if not self.is_staff(member) or member.bot: - return - - # Does the message already have a green checkmark? - if await self.has_green_checkmark(message): - return - - # Time to count our ducks! - duck_count = await self.count_ducks(message) - - # If we've got more than the required amount of ducks, send the message to the duck_pond. - if duck_count >= constants.DuckPond.threshold: - await self.relay_message(message) - - @Cog.listener() - async def on_raw_reaction_remove(self, payload: RawReactionActionEvent) -> None: - """Ensure that people don't remove the green checkmark from duck ponded messages.""" - channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id) - - # Prevent the green checkmark from being removed - if payload.emoji.name == "✅": - message = await channel.fetch_message(payload.message_id) - duck_count = await self.count_ducks(message) - if duck_count >= constants.DuckPond.threshold: - await message.add_reaction("✅") - - -def setup(bot: Bot) -> None: - """Load the DuckPond cog.""" - bot.add_cog(DuckPond(bot)) diff --git a/bot/exts/fun/__init__.py b/bot/exts/fun/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py new file mode 100644 index 000000000..7021069fa --- /dev/null +++ b/bot/exts/fun/duck_pond.py @@ -0,0 +1,166 @@ +import logging +from typing import Union + +import discord +from discord import Color, Embed, Member, Message, RawReactionActionEvent, User, errors +from discord.ext.commands import Cog + +from bot import constants +from bot.bot import Bot +from bot.utils.messages import send_attachments +from bot.utils.webhooks import send_webhook + +log = logging.getLogger(__name__) + + +class DuckPond(Cog): + """Relays messages to #duck-pond whenever a certain number of duck reactions have been achieved.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.webhook_id = constants.Webhooks.duck_pond + self.webhook = None + self.bot.loop.create_task(self.fetch_webhook()) + + async def fetch_webhook(self) -> None: + """Fetches the webhook object, so we can post to it.""" + await self.bot.wait_until_guild_available() + + try: + self.webhook = await self.bot.fetch_webhook(self.webhook_id) + except discord.HTTPException: + log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") + + @staticmethod + def is_staff(member: Union[User, Member]) -> bool: + """Check if a specific member or user is staff.""" + if hasattr(member, "roles"): + for role in member.roles: + if role.id in constants.STAFF_ROLES: + return True + return False + + async def has_green_checkmark(self, message: Message) -> bool: + """Check if the message has a green checkmark reaction.""" + for reaction in message.reactions: + if reaction.emoji == "✅": + async for user in reaction.users(): + if user == self.bot.user: + return True + return False + + async def count_ducks(self, message: Message) -> int: + """ + Count the number of ducks in the reactions of a specific message. + + Only counts ducks added by staff members. + """ + duck_count = 0 + duck_reactors = [] + + for reaction in message.reactions: + async for user in reaction.users(): + + # Is the user a staff member and not already counted as reactor? + if not self.is_staff(user) or user.id in duck_reactors: + continue + + # Is the emoji a duck? + if hasattr(reaction.emoji, "id"): + if reaction.emoji.id in constants.DuckPond.custom_emojis: + duck_count += 1 + duck_reactors.append(user.id) + elif isinstance(reaction.emoji, str): + if reaction.emoji == "🦆": + duck_count += 1 + duck_reactors.append(user.id) + return duck_count + + async def relay_message(self, message: Message) -> None: + """Relays the message's content and attachments to the duck pond channel.""" + if message.clean_content: + await send_webhook( + webhook=self.webhook, + content=message.clean_content, + username=message.author.display_name, + avatar_url=message.author.avatar_url + ) + + if message.attachments: + try: + await send_attachments(message, self.webhook) + except (errors.Forbidden, errors.NotFound): + e = Embed( + description=":x: **This message contained an attachment, but it could not be retrieved**", + color=Color.red() + ) + await send_webhook( + webhook=self.webhook, + embed=e, + username=message.author.display_name, + avatar_url=message.author.avatar_url + ) + except discord.HTTPException: + log.exception("Failed to send an attachment to the webhook") + + await message.add_reaction("✅") + + @staticmethod + def _payload_has_duckpond_emoji(payload: RawReactionActionEvent) -> bool: + """Test if the RawReactionActionEvent payload contains a duckpond emoji.""" + if payload.emoji.is_custom_emoji(): + if payload.emoji.id in constants.DuckPond.custom_emojis: + return True + elif payload.emoji.name == "🦆": + return True + + return False + + @Cog.listener() + async def on_raw_reaction_add(self, payload: RawReactionActionEvent) -> None: + """ + Determine if a message should be sent to the duck pond. + + This will count the number of duck reactions on the message, and if this amount meets the + amount of ducks specified in the config under duck_pond/threshold, it will + send the message off to the duck pond. + """ + # Is the emoji in the reaction a duck? + if not self._payload_has_duckpond_emoji(payload): + return + + channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id) + message = await channel.fetch_message(payload.message_id) + member = discord.utils.get(message.guild.members, id=payload.user_id) + + # Is the member a human and a staff member? + if not self.is_staff(member) or member.bot: + return + + # Does the message already have a green checkmark? + if await self.has_green_checkmark(message): + return + + # Time to count our ducks! + duck_count = await self.count_ducks(message) + + # If we've got more than the required amount of ducks, send the message to the duck_pond. + if duck_count >= constants.DuckPond.threshold: + await self.relay_message(message) + + @Cog.listener() + async def on_raw_reaction_remove(self, payload: RawReactionActionEvent) -> None: + """Ensure that people don't remove the green checkmark from duck ponded messages.""" + channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id) + + # Prevent the green checkmark from being removed + if payload.emoji.name == "✅": + message = await channel.fetch_message(payload.message_id) + duck_count = await self.count_ducks(message) + if duck_count >= constants.DuckPond.threshold: + await message.add_reaction("✅") + + +def setup(bot: Bot) -> None: + """Load the DuckPond cog.""" + bot.add_cog(DuckPond(bot)) diff --git a/bot/exts/fun/off_topic_names.py b/bot/exts/fun/off_topic_names.py new file mode 100644 index 000000000..ce95450e0 --- /dev/null +++ b/bot/exts/fun/off_topic_names.py @@ -0,0 +1,162 @@ +import asyncio +import difflib +import logging +from datetime import datetime, timedelta + +from discord import Colour, Embed +from discord.ext.commands import Cog, Context, group + +from bot.api import ResponseCodeError +from bot.bot import Bot +from bot.constants import Channels, MODERATION_ROLES +from bot.converters import OffTopicName +from bot.decorators import with_role +from bot.pagination import LinePaginator + +CHANNELS = (Channels.off_topic_0, Channels.off_topic_1, Channels.off_topic_2) +log = logging.getLogger(__name__) + + +async def update_names(bot: Bot) -> None: + """Background updater task that performs the daily channel name update.""" + while True: + # Since we truncate the compute timedelta to seconds, we add one second to ensure + # we go past midnight in the `seconds_to_sleep` set below. + today_at_midnight = datetime.utcnow().replace(microsecond=0, second=0, minute=0, hour=0) + next_midnight = today_at_midnight + timedelta(days=1) + seconds_to_sleep = (next_midnight - datetime.utcnow()).seconds + 1 + await asyncio.sleep(seconds_to_sleep) + + try: + channel_0_name, channel_1_name, channel_2_name = await bot.api_client.get( + 'bot/off-topic-channel-names', params={'random_items': 3} + ) + except ResponseCodeError as e: + log.error(f"Failed to get new off topic channel names: code {e.response.status}") + continue + channel_0, channel_1, channel_2 = (bot.get_channel(channel_id) for channel_id in CHANNELS) + + await channel_0.edit(name=f'ot0-{channel_0_name}') + await channel_1.edit(name=f'ot1-{channel_1_name}') + await channel_2.edit(name=f'ot2-{channel_2_name}') + log.debug( + "Updated off-topic channel names to" + f" {channel_0_name}, {channel_1_name} and {channel_2_name}" + ) + + +class OffTopicNames(Cog): + """Commands related to managing the off-topic category channel names.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.updater_task = None + + self.bot.loop.create_task(self.init_offtopic_updater()) + + def cog_unload(self) -> None: + """Cancel any running updater tasks on cog unload.""" + if self.updater_task is not None: + self.updater_task.cancel() + + async def init_offtopic_updater(self) -> None: + """Start off-topic channel updating event loop if it hasn't already started.""" + await self.bot.wait_until_guild_available() + if self.updater_task is None: + coro = update_names(self.bot) + self.updater_task = self.bot.loop.create_task(coro) + + @group(name='otname', aliases=('otnames', 'otn'), invoke_without_command=True) + @with_role(*MODERATION_ROLES) + async def otname_group(self, ctx: Context) -> None: + """Add or list items from the off-topic channel name rotation.""" + await ctx.send_help(ctx.command) + + @otname_group.command(name='add', aliases=('a',)) + @with_role(*MODERATION_ROLES) + async def add_command(self, ctx: Context, *, name: OffTopicName) -> None: + """ + Adds a new off-topic name to the rotation. + + The name is not added if it is too similar to an existing name. + """ + existing_names = await self.bot.api_client.get('bot/off-topic-channel-names') + close_match = difflib.get_close_matches(name, existing_names, n=1, cutoff=0.8) + + if close_match: + match = close_match[0] + log.info( + f"{ctx.author} tried to add channel name '{name}' but it was too similar to '{match}'" + ) + await ctx.send( + f":x: The channel name `{name}` is too similar to `{match}`, and thus was not added. " + "Use `!otn forceadd` to override this check." + ) + else: + await self._add_name(ctx, name) + + @otname_group.command(name='forceadd', aliases=('fa',)) + @with_role(*MODERATION_ROLES) + async def force_add_command(self, ctx: Context, *, name: OffTopicName) -> None: + """Forcefully adds a new off-topic name to the rotation.""" + await self._add_name(ctx, name) + + async def _add_name(self, ctx: Context, name: str) -> None: + """Adds an off-topic channel name to the site storage.""" + await self.bot.api_client.post('bot/off-topic-channel-names', params={'name': name}) + + log.info(f"{ctx.author} added the off-topic channel name '{name}'") + await ctx.send(f":ok_hand: Added `{name}` to the names list.") + + @otname_group.command(name='delete', aliases=('remove', 'rm', 'del', 'd')) + @with_role(*MODERATION_ROLES) + async def delete_command(self, ctx: Context, *, name: OffTopicName) -> None: + """Removes a off-topic name from the rotation.""" + await self.bot.api_client.delete(f'bot/off-topic-channel-names/{name}') + + log.info(f"{ctx.author} deleted the off-topic channel name '{name}'") + await ctx.send(f":ok_hand: Removed `{name}` from the names list.") + + @otname_group.command(name='list', aliases=('l',)) + @with_role(*MODERATION_ROLES) + async def list_command(self, ctx: Context) -> None: + """ + Lists all currently known off-topic channel names in a paginator. + + Restricted to Moderator and above to not spoil the surprise. + """ + result = await self.bot.api_client.get('bot/off-topic-channel-names') + lines = sorted(f"• {name}" for name in result) + embed = Embed( + title=f"Known off-topic names (`{len(result)}` total)", + colour=Colour.blue() + ) + if result: + await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False) + else: + embed.description = "Hmmm, seems like there's nothing here yet." + await ctx.send(embed=embed) + + @otname_group.command(name='search', aliases=('s',)) + @with_role(*MODERATION_ROLES) + async def search_command(self, ctx: Context, *, query: OffTopicName) -> None: + """Search for an off-topic name.""" + result = await self.bot.api_client.get('bot/off-topic-channel-names') + in_matches = {name for name in result if query in name} + close_matches = difflib.get_close_matches(query, result, n=10, cutoff=0.70) + lines = sorted(f"• {name}" for name in in_matches.union(close_matches)) + embed = Embed( + title="Query results", + colour=Colour.blue() + ) + + if lines: + await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False) + else: + embed.description = "Nothing found." + await ctx.send(embed=embed) + + +def setup(bot: Bot) -> None: + """Load the OffTopicNames cog.""" + bot.add_cog(OffTopicNames(bot)) diff --git a/bot/exts/moderation/dm_relay.py b/bot/exts/moderation/dm_relay.py new file mode 100644 index 000000000..0d8f340b4 --- /dev/null +++ b/bot/exts/moderation/dm_relay.py @@ -0,0 +1,124 @@ +import logging +from typing import Optional + +import discord +from discord import Color +from discord.ext import commands +from discord.ext.commands import Cog + +from bot import constants +from bot.bot import Bot +from bot.converters import UserMentionOrID +from bot.utils import RedisCache +from bot.utils.checks import in_whitelist_check, with_role_check +from bot.utils.messages import send_attachments +from bot.utils.webhooks import send_webhook + +log = logging.getLogger(__name__) + + +class DMRelay(Cog): + """Relay direct messages to and from the bot.""" + + # RedisCache[str, t.Union[discord.User.id, discord.Member.id]] + dm_cache = RedisCache() + + def __init__(self, bot: Bot): + self.bot = bot + self.webhook_id = constants.Webhooks.dm_log + self.webhook = None + self.bot.loop.create_task(self.fetch_webhook()) + + @commands.command(aliases=("reply",)) + async def send_dm(self, ctx: commands.Context, member: Optional[UserMentionOrID], *, message: str) -> None: + """ + Allows you to send a DM to a user from the bot. + + If `member` is not provided, it will send to the last user who DM'd the bot. + + This feature should be used extremely sparingly. Use ModMail if you need to have a serious + conversation with a user. This is just for responding to extraordinary DMs, having a little + fun with users, and telling people they are DMing the wrong bot. + + NOTE: This feature will be removed if it is overused. + """ + if not member: + user_id = await self.dm_cache.get("last_user") + member = ctx.guild.get_member(user_id) if user_id else None + + # If we still don't have a Member at this point, give up + if not member: + log.debug("This bot has never gotten a DM, or the RedisCache has been cleared.") + await ctx.message.add_reaction("❌") + return + + try: + await member.send(message) + except discord.errors.Forbidden: + log.debug("User has disabled DMs.") + await ctx.message.add_reaction("❌") + else: + await ctx.message.add_reaction("✅") + self.bot.stats.incr("dm_relay.dm_sent") + + async def fetch_webhook(self) -> None: + """Fetches the webhook object, so we can post to it.""" + await self.bot.wait_until_guild_available() + + try: + self.webhook = await self.bot.fetch_webhook(self.webhook_id) + except discord.HTTPException: + log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") + + @Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """Relays the message's content and attachments to the dm_log channel.""" + # Only relay DMs from humans + if message.author.bot or message.guild or self.webhook is None: + return + + if message.clean_content: + await send_webhook( + webhook=self.webhook, + content=message.clean_content, + username=f"{message.author.display_name} ({message.author.id})", + avatar_url=message.author.avatar_url + ) + await self.dm_cache.set("last_user", message.author.id) + self.bot.stats.incr("dm_relay.dm_received") + + # Handle any attachments + if message.attachments: + try: + await send_attachments(message, self.webhook) + except (discord.errors.Forbidden, discord.errors.NotFound): + e = discord.Embed( + description=":x: **This message contained an attachment, but it could not be retrieved**", + color=Color.red() + ) + await send_webhook( + webhook=self.webhook, + embed=e, + username=f"{message.author.display_name} ({message.author.id})", + avatar_url=message.author.avatar_url + ) + except discord.HTTPException: + log.exception("Failed to send an attachment to the webhook") + + def cog_check(self, ctx: commands.Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + checks = [ + with_role_check(ctx, *constants.MODERATION_ROLES), + in_whitelist_check( + ctx, + channels=[constants.Channels.dm_log], + redirect=None, + fail_silently=True, + ) + ] + return all(checks) + + +def setup(bot: Bot) -> None: + """Load the DMRelay cog.""" + bot.add_cog(DMRelay(bot)) diff --git a/bot/exts/off_topic_names.py b/bot/exts/off_topic_names.py deleted file mode 100644 index ce95450e0..000000000 --- a/bot/exts/off_topic_names.py +++ /dev/null @@ -1,162 +0,0 @@ -import asyncio -import difflib -import logging -from datetime import datetime, timedelta - -from discord import Colour, Embed -from discord.ext.commands import Cog, Context, group - -from bot.api import ResponseCodeError -from bot.bot import Bot -from bot.constants import Channels, MODERATION_ROLES -from bot.converters import OffTopicName -from bot.decorators import with_role -from bot.pagination import LinePaginator - -CHANNELS = (Channels.off_topic_0, Channels.off_topic_1, Channels.off_topic_2) -log = logging.getLogger(__name__) - - -async def update_names(bot: Bot) -> None: - """Background updater task that performs the daily channel name update.""" - while True: - # Since we truncate the compute timedelta to seconds, we add one second to ensure - # we go past midnight in the `seconds_to_sleep` set below. - today_at_midnight = datetime.utcnow().replace(microsecond=0, second=0, minute=0, hour=0) - next_midnight = today_at_midnight + timedelta(days=1) - seconds_to_sleep = (next_midnight - datetime.utcnow()).seconds + 1 - await asyncio.sleep(seconds_to_sleep) - - try: - channel_0_name, channel_1_name, channel_2_name = await bot.api_client.get( - 'bot/off-topic-channel-names', params={'random_items': 3} - ) - except ResponseCodeError as e: - log.error(f"Failed to get new off topic channel names: code {e.response.status}") - continue - channel_0, channel_1, channel_2 = (bot.get_channel(channel_id) for channel_id in CHANNELS) - - await channel_0.edit(name=f'ot0-{channel_0_name}') - await channel_1.edit(name=f'ot1-{channel_1_name}') - await channel_2.edit(name=f'ot2-{channel_2_name}') - log.debug( - "Updated off-topic channel names to" - f" {channel_0_name}, {channel_1_name} and {channel_2_name}" - ) - - -class OffTopicNames(Cog): - """Commands related to managing the off-topic category channel names.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.updater_task = None - - self.bot.loop.create_task(self.init_offtopic_updater()) - - def cog_unload(self) -> None: - """Cancel any running updater tasks on cog unload.""" - if self.updater_task is not None: - self.updater_task.cancel() - - async def init_offtopic_updater(self) -> None: - """Start off-topic channel updating event loop if it hasn't already started.""" - await self.bot.wait_until_guild_available() - if self.updater_task is None: - coro = update_names(self.bot) - self.updater_task = self.bot.loop.create_task(coro) - - @group(name='otname', aliases=('otnames', 'otn'), invoke_without_command=True) - @with_role(*MODERATION_ROLES) - async def otname_group(self, ctx: Context) -> None: - """Add or list items from the off-topic channel name rotation.""" - await ctx.send_help(ctx.command) - - @otname_group.command(name='add', aliases=('a',)) - @with_role(*MODERATION_ROLES) - async def add_command(self, ctx: Context, *, name: OffTopicName) -> None: - """ - Adds a new off-topic name to the rotation. - - The name is not added if it is too similar to an existing name. - """ - existing_names = await self.bot.api_client.get('bot/off-topic-channel-names') - close_match = difflib.get_close_matches(name, existing_names, n=1, cutoff=0.8) - - if close_match: - match = close_match[0] - log.info( - f"{ctx.author} tried to add channel name '{name}' but it was too similar to '{match}'" - ) - await ctx.send( - f":x: The channel name `{name}` is too similar to `{match}`, and thus was not added. " - "Use `!otn forceadd` to override this check." - ) - else: - await self._add_name(ctx, name) - - @otname_group.command(name='forceadd', aliases=('fa',)) - @with_role(*MODERATION_ROLES) - async def force_add_command(self, ctx: Context, *, name: OffTopicName) -> None: - """Forcefully adds a new off-topic name to the rotation.""" - await self._add_name(ctx, name) - - async def _add_name(self, ctx: Context, name: str) -> None: - """Adds an off-topic channel name to the site storage.""" - await self.bot.api_client.post('bot/off-topic-channel-names', params={'name': name}) - - log.info(f"{ctx.author} added the off-topic channel name '{name}'") - await ctx.send(f":ok_hand: Added `{name}` to the names list.") - - @otname_group.command(name='delete', aliases=('remove', 'rm', 'del', 'd')) - @with_role(*MODERATION_ROLES) - async def delete_command(self, ctx: Context, *, name: OffTopicName) -> None: - """Removes a off-topic name from the rotation.""" - await self.bot.api_client.delete(f'bot/off-topic-channel-names/{name}') - - log.info(f"{ctx.author} deleted the off-topic channel name '{name}'") - await ctx.send(f":ok_hand: Removed `{name}` from the names list.") - - @otname_group.command(name='list', aliases=('l',)) - @with_role(*MODERATION_ROLES) - async def list_command(self, ctx: Context) -> None: - """ - Lists all currently known off-topic channel names in a paginator. - - Restricted to Moderator and above to not spoil the surprise. - """ - result = await self.bot.api_client.get('bot/off-topic-channel-names') - lines = sorted(f"• {name}" for name in result) - embed = Embed( - title=f"Known off-topic names (`{len(result)}` total)", - colour=Colour.blue() - ) - if result: - await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False) - else: - embed.description = "Hmmm, seems like there's nothing here yet." - await ctx.send(embed=embed) - - @otname_group.command(name='search', aliases=('s',)) - @with_role(*MODERATION_ROLES) - async def search_command(self, ctx: Context, *, query: OffTopicName) -> None: - """Search for an off-topic name.""" - result = await self.bot.api_client.get('bot/off-topic-channel-names') - in_matches = {name for name in result if query in name} - close_matches = difflib.get_close_matches(query, result, n=10, cutoff=0.70) - lines = sorted(f"• {name}" for name in in_matches.union(close_matches)) - embed = Embed( - title="Query results", - colour=Colour.blue() - ) - - if lines: - await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False) - else: - embed.description = "Nothing found." - await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the OffTopicNames cog.""" - bot.add_cog(OffTopicNames(bot)) diff --git a/tests/bot/exts/fun/__init__.py b/tests/bot/exts/fun/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/exts/fun/test_duck_pond.py b/tests/bot/exts/fun/test_duck_pond.py new file mode 100644 index 000000000..704b08066 --- /dev/null +++ b/tests/bot/exts/fun/test_duck_pond.py @@ -0,0 +1,548 @@ +import asyncio +import logging +import typing +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +import discord + +from bot import constants +from bot.exts.fun import duck_pond +from tests import base +from tests import helpers + +MODULE_PATH = "bot.exts.fun.duck_pond" + + +class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): + """Tests for DuckPond functionality.""" + + @classmethod + def setUpClass(cls): + """Sets up the objects that only have to be initialized once.""" + cls.nonstaff_member = helpers.MockMember(name="Non-staffer") + + cls.staff_role = helpers.MockRole(name="Staff role", id=constants.STAFF_ROLES[0]) + cls.staff_member = helpers.MockMember(name="staffer", roles=[cls.staff_role]) + + cls.checkmark_emoji = "\N{White Heavy Check Mark}" + cls.thumbs_up_emoji = "\N{Thumbs Up Sign}" + cls.unicode_duck_emoji = "\N{Duck}" + cls.duck_pond_emoji = helpers.MockPartialEmoji(id=constants.DuckPond.custom_emojis[0]) + cls.non_duck_custom_emoji = helpers.MockPartialEmoji(id=123) + + def setUp(self): + """Sets up the objects that need to be refreshed before each test.""" + self.bot = helpers.MockBot(user=helpers.MockMember(id=46692)) + self.cog = duck_pond.DuckPond(bot=self.bot) + + def test_duck_pond_correctly_initializes(self): + """`__init__ should set `bot` and `webhook_id` attributes and schedule `fetch_webhook`.""" + bot = helpers.MockBot() + cog = MagicMock() + + duck_pond.DuckPond.__init__(cog, bot) + + self.assertEqual(cog.bot, bot) + self.assertEqual(cog.webhook_id, constants.Webhooks.duck_pond) + bot.loop.create_task.assert_called_once_with(cog.fetch_webhook()) + + def test_fetch_webhook_succeeds_without_connectivity_issues(self): + """The `fetch_webhook` method waits until `READY` event and sets the `webhook` attribute.""" + self.bot.fetch_webhook.return_value = "dummy webhook" + self.cog.webhook_id = 1 + + asyncio.run(self.cog.fetch_webhook()) + + self.bot.wait_until_guild_available.assert_called_once() + self.bot.fetch_webhook.assert_called_once_with(1) + self.assertEqual(self.cog.webhook, "dummy webhook") + + def test_fetch_webhook_logs_when_unable_to_fetch_webhook(self): + """The `fetch_webhook` method should log an exception when it fails to fetch the webhook.""" + self.bot.fetch_webhook.side_effect = discord.HTTPException(response=MagicMock(), message="Not found.") + self.cog.webhook_id = 1 + + log = logging.getLogger(MODULE_PATH) + with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: + asyncio.run(self.cog.fetch_webhook()) + + self.bot.wait_until_guild_available.assert_called_once() + self.bot.fetch_webhook.assert_called_once_with(1) + + self.assertEqual(len(log_watcher.records), 1) + + record = log_watcher.records[0] + self.assertEqual(record.levelno, logging.ERROR) + + def test_is_staff_returns_correct_values_based_on_instance_passed(self): + """The `is_staff` method should return correct values based on the instance passed.""" + test_cases = ( + (helpers.MockUser(name="User instance"), False), + (helpers.MockMember(name="Member instance without staff role"), False), + (helpers.MockMember(name="Member instance with staff role", roles=[self.staff_role]), True) + ) + + for user, expected_return in test_cases: + actual_return = self.cog.is_staff(user) + with self.subTest(user_type=user.name, expected_return=expected_return, actual_return=actual_return): + self.assertEqual(expected_return, actual_return) + + async def test_has_green_checkmark_correctly_detects_presence_of_green_checkmark_emoji(self): + """The `has_green_checkmark` method should only return `True` if one is present.""" + test_cases = ( + ( + "No reactions", helpers.MockMessage(), False + ), + ( + "No green check mark reactions", + helpers.MockMessage(reactions=[ + helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]), + helpers.MockReaction(emoji=self.thumbs_up_emoji, users=[self.bot.user]) + ]), + False + ), + ( + "Green check mark reaction, but not from our bot", + helpers.MockMessage(reactions=[ + helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]), + helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.staff_member]) + ]), + False + ), + ( + "Green check mark reaction, with one from the bot", + helpers.MockMessage(reactions=[ + helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]), + helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.staff_member, self.bot.user]) + ]), + True + ) + ) + + for description, message, expected_return in test_cases: + actual_return = await self.cog.has_green_checkmark(message) + with self.subTest( + test_case=description, + expected_return=expected_return, + actual_return=actual_return + ): + self.assertEqual(expected_return, actual_return) + + def _get_reaction( + self, + emoji: typing.Union[str, helpers.MockEmoji], + staff: int = 0, + nonstaff: int = 0 + ) -> helpers.MockReaction: + staffers = [helpers.MockMember(roles=[self.staff_role]) for _ in range(staff)] + nonstaffers = [helpers.MockMember() for _ in range(nonstaff)] + return helpers.MockReaction(emoji=emoji, users=staffers + nonstaffers) + + async def test_count_ducks_correctly_counts_the_number_of_eligible_duck_emojis(self): + """The `count_ducks` method should return the number of unique staffers who gave a duck.""" + test_cases = ( + # Simple test cases + # A message without reactions should return 0 + ( + "No reactions", + helpers.MockMessage(), + 0 + ), + # A message with a non-duck reaction from a non-staffer should return 0 + ( + "Non-duck reaction from non-staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.thumbs_up_emoji, nonstaff=1)]), + 0 + ), + # A message with a non-duck reaction from a staffer should return 0 + ( + "Non-duck reaction from staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.non_duck_custom_emoji, staff=1)]), + 0 + ), + # A message with a non-duck reaction from a non-staffer and staffer should return 0 + ( + "Non-duck reaction from staffer + non-staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.thumbs_up_emoji, staff=1, nonstaff=1)]), + 0 + ), + # A message with a unicode duck reaction from a non-staffer should return 0 + ( + "Unicode Duck Reaction from non-staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, nonstaff=1)]), + 0 + ), + # A message with a unicode duck reaction from a staffer should return 1 + ( + "Unicode Duck Reaction from staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, staff=1)]), + 1 + ), + # A message with a unicode duck reaction from a non-staffer and staffer should return 1 + ( + "Unicode Duck Reaction from staffer + non-staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, staff=1, nonstaff=1)]), + 1 + ), + # A message with a duckpond duck reaction from a non-staffer should return 0 + ( + "Duckpond Duck Reaction from non-staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, nonstaff=1)]), + 0 + ), + # A message with a duckpond duck reaction from a staffer should return 1 + ( + "Duckpond Duck Reaction from staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=1)]), + 1 + ), + # A message with a duckpond duck reaction from a non-staffer and staffer should return 1 + ( + "Duckpond Duck Reaction from staffer + non-staffer", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=1, nonstaff=1)]), + 1 + ), + + # Complex test cases + # A message with duckpond duck reactions from 3 staffers and 2 non-staffers returns 3 + ( + "Duckpond Duck Reaction from 3 staffers + 2 non-staffers", + helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=3, nonstaff=2)]), + 3 + ), + # A staffer with multiple duck reactions only counts once + ( + "Two different duck reactions from the same staffer", + helpers.MockMessage( + reactions=[ + helpers.MockReaction(emoji=self.duck_pond_emoji, users=[self.staff_member]), + helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.staff_member]), + ] + ), + 1 + ), + # A non-string emoji does not count (to test the `isinstance(reaction.emoji, str)` elif) + ( + "Reaction with non-Emoji/str emoij from 3 staffers + 2 non-staffers", + helpers.MockMessage(reactions=[self._get_reaction(emoji=100, staff=3, nonstaff=2)]), + 0 + ), + # We correctly sum when multiple reactions are provided. + ( + "Duckpond Duck Reaction from 3 staffers + 2 non-staffers", + helpers.MockMessage( + reactions=[ + self._get_reaction(emoji=self.duck_pond_emoji, staff=3, nonstaff=2), + self._get_reaction(emoji=self.unicode_duck_emoji, staff=4, nonstaff=9), + ] + ), + 3 + 4 + ), + ) + + for description, message, expected_count in test_cases: + actual_count = await self.cog.count_ducks(message) + with self.subTest(test_case=description, expected_count=expected_count, actual_count=actual_count): + self.assertEqual(expected_count, actual_count) + + async def test_relay_message_correctly_relays_content_and_attachments(self): + """The `relay_message` method should correctly relay message content and attachments.""" + send_webhook_path = f"{MODULE_PATH}.send_webhook" + send_attachments_path = f"{MODULE_PATH}.send_attachments" + author = MagicMock( + display_name="x", + avatar_url="https://" + ) + + self.cog.webhook = helpers.MockAsyncWebhook() + + test_values = ( + (helpers.MockMessage(author=author, clean_content="", attachments=[]), False, False), + (helpers.MockMessage(author=author, clean_content="message", attachments=[]), True, False), + (helpers.MockMessage(author=author, clean_content="", attachments=["attachment"]), False, True), + (helpers.MockMessage(author=author, clean_content="message", attachments=["attachment"]), True, True), + ) + + for message, expect_webhook_call, expect_attachment_call in test_values: + with patch(send_webhook_path, new_callable=AsyncMock) as send_webhook: + with patch(send_attachments_path, new_callable=AsyncMock) as send_attachments: + with self.subTest(clean_content=message.clean_content, attachments=message.attachments): + await self.cog.relay_message(message) + + self.assertEqual(expect_webhook_call, send_webhook.called) + self.assertEqual(expect_attachment_call, send_attachments.called) + + message.add_reaction.assert_called_once_with(self.checkmark_emoji) + + @patch(f"{MODULE_PATH}.send_attachments", new_callable=AsyncMock) + async def test_relay_message_handles_irretrievable_attachment_exceptions(self, send_attachments): + """The `relay_message` method should handle irretrievable attachments.""" + message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) + side_effects = (discord.errors.Forbidden(MagicMock(), ""), discord.errors.NotFound(MagicMock(), "")) + + self.cog.webhook = helpers.MockAsyncWebhook() + log = logging.getLogger(MODULE_PATH) + + for side_effect in side_effects: # pragma: no cover + send_attachments.side_effect = side_effect + with patch(f"{MODULE_PATH}.send_webhook", new_callable=AsyncMock) as send_webhook: + with self.subTest(side_effect=type(side_effect).__name__): + with self.assertNotLogs(logger=log, level=logging.ERROR): + await self.cog.relay_message(message) + + self.assertEqual(send_webhook.call_count, 2) + + @patch(f"{MODULE_PATH}.send_webhook", new_callable=AsyncMock) + @patch(f"{MODULE_PATH}.send_attachments", new_callable=AsyncMock) + async def test_relay_message_handles_attachment_http_error(self, send_attachments, send_webhook): + """The `relay_message` method should handle irretrievable attachments.""" + message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) + + self.cog.webhook = helpers.MockAsyncWebhook() + log = logging.getLogger(MODULE_PATH) + + side_effect = discord.HTTPException(MagicMock(), "") + send_attachments.side_effect = side_effect + with self.subTest(side_effect=type(side_effect).__name__): + with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: + await self.cog.relay_message(message) + + send_webhook.assert_called_once_with( + webhook=self.cog.webhook, + content=message.clean_content, + username=message.author.display_name, + avatar_url=message.author.avatar_url + ) + + self.assertEqual(len(log_watcher.records), 1) + + record = log_watcher.records[0] + self.assertEqual(record.levelno, logging.ERROR) + + def _mock_payload(self, label: str, is_custom_emoji: bool, id_: int, emoji_name: str): + """Creates a mock `on_raw_reaction_add` payload with the specified emoji data.""" + payload = MagicMock(name=label) + payload.emoji.is_custom_emoji.return_value = is_custom_emoji + payload.emoji.id = id_ + payload.emoji.name = emoji_name + return payload + + async def test_payload_has_duckpond_emoji_correctly_detects_relevant_emojis(self): + """The `on_raw_reaction_add` event handler should ignore irrelevant emojis.""" + test_values = ( + # Custom Emojis + ( + self._mock_payload( + label="Custom Duckpond Emoji", + is_custom_emoji=True, + id_=constants.DuckPond.custom_emojis[0], + emoji_name="" + ), + True + ), + ( + self._mock_payload( + label="Custom Non-Duckpond Emoji", + is_custom_emoji=True, + id_=123, + emoji_name="" + ), + False + ), + # Unicode Emojis + ( + self._mock_payload( + label="Unicode Duck Emoji", + is_custom_emoji=False, + id_=1, + emoji_name=self.unicode_duck_emoji + ), + True + ), + ( + self._mock_payload( + label="Unicode Non-Duck Emoji", + is_custom_emoji=False, + id_=1, + emoji_name=self.thumbs_up_emoji + ), + False + ), + ) + + for payload, expected_return in test_values: + actual_return = self.cog._payload_has_duckpond_emoji(payload) + with self.subTest(case=payload._mock_name, expected_return=expected_return, actual_return=actual_return): + self.assertEqual(expected_return, actual_return) + + @patch(f"{MODULE_PATH}.discord.utils.get") + @patch(f"{MODULE_PATH}.DuckPond._payload_has_duckpond_emoji", new=MagicMock(return_value=False)) + def test_on_raw_reaction_add_returns_early_with_payload_without_duck_emoji(self, utils_get): + """The `on_raw_reaction_add` method should return early if the payload does not contain a duck emoji.""" + self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_add(payload=MagicMock()))) + + # Ensure we've returned before making an unnecessary API call in the lines of code after the emoji check + utils_get.assert_not_called() + + def _raw_reaction_mocks(self, channel_id, message_id, user_id): + """Sets up mocks for tests of the `on_raw_reaction_add` event listener.""" + channel = helpers.MockTextChannel(id=channel_id) + self.bot.get_all_channels.return_value = (channel,) + + message = helpers.MockMessage(id=message_id) + + channel.fetch_message.return_value = message + + member = helpers.MockMember(id=user_id, roles=[self.staff_role]) + message.guild.members = (member,) + + payload = MagicMock(channel_id=channel_id, message_id=message_id, user_id=user_id) + + return channel, message, member, payload + + async def test_on_raw_reaction_add_returns_for_bot_and_non_staff_members(self): + """The `on_raw_reaction_add` event handler should return for bot users or non-staff members.""" + channel_id = 1234 + message_id = 2345 + user_id = 3456 + + channel, message, _, payload = self._raw_reaction_mocks(channel_id, message_id, user_id) + + test_cases = ( + ("non-staff member", helpers.MockMember(id=user_id)), + ("bot staff member", helpers.MockMember(id=user_id, roles=[self.staff_role], bot=True)), + ) + + payload.emoji = self.duck_pond_emoji + + for description, member in test_cases: + message.guild.members = (member, ) + with self.subTest(test_case=description), patch(f"{MODULE_PATH}.DuckPond.has_green_checkmark") as checkmark: + checkmark.side_effect = AssertionError( + "Expected method to return before calling `self.has_green_checkmark`." + ) + self.assertIsNone(await self.cog.on_raw_reaction_add(payload)) + + # Check that we did make it past the payload checks + channel.fetch_message.assert_called_once() + channel.fetch_message.reset_mock() + + @patch(f"{MODULE_PATH}.DuckPond.is_staff") + @patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) + def test_on_raw_reaction_add_returns_on_message_with_green_checkmark_placed_by_bot(self, count_ducks, is_staff): + """The `on_raw_reaction_add` event should return when the message has a green check mark placed by the bot.""" + channel_id = 31415926535 + message_id = 27182818284 + user_id = 16180339887 + + channel, message, member, payload = self._raw_reaction_mocks(channel_id, message_id, user_id) + + payload.emoji = helpers.MockPartialEmoji(name=self.unicode_duck_emoji) + payload.emoji.is_custom_emoji.return_value = False + + message.reactions = [helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.bot.user])] + + is_staff.return_value = True + count_ducks.side_effect = AssertionError("Expected method to return before calling `self.count_ducks`") + + self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_add(payload))) + + # Assert that we've made it past `self.is_staff` + is_staff.assert_called_once() + + async def test_on_raw_reaction_add_does_not_relay_below_duck_threshold(self): + """The `on_raw_reaction_add` listener should not relay messages or attachments below the duck threshold.""" + test_cases = ( + (constants.DuckPond.threshold - 1, False), + (constants.DuckPond.threshold, True), + (constants.DuckPond.threshold + 1, True), + ) + + channel, message, member, payload = self._raw_reaction_mocks(channel_id=3, message_id=4, user_id=5) + + payload.emoji = self.duck_pond_emoji + + for duck_count, should_relay in test_cases: + with patch(f"{MODULE_PATH}.DuckPond.relay_message", new_callable=AsyncMock) as relay_message: + with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) as count_ducks: + count_ducks.return_value = duck_count + with self.subTest(duck_count=duck_count, should_relay=should_relay): + await self.cog.on_raw_reaction_add(payload) + + # Confirm that we've made it past counting + count_ducks.assert_called_once() + + # Did we relay a message? + has_relayed = relay_message.called + self.assertEqual(has_relayed, should_relay) + + if should_relay: + relay_message.assert_called_once_with(message) + + async def test_on_raw_reaction_remove_prevents_removal_of_green_checkmark_depending_on_the_duck_count(self): + """The `on_raw_reaction_remove` listener prevents removal of the check mark on messages with enough ducks.""" + checkmark = helpers.MockPartialEmoji(name=self.checkmark_emoji) + + message = helpers.MockMessage(id=1234) + + channel = helpers.MockTextChannel(id=98765) + channel.fetch_message.return_value = message + + self.bot.get_all_channels.return_value = (channel, ) + + payload = MagicMock(channel_id=channel.id, message_id=message.id, emoji=checkmark) + + test_cases = ( + (constants.DuckPond.threshold - 1, False), + (constants.DuckPond.threshold, True), + (constants.DuckPond.threshold + 1, True), + ) + for duck_count, should_re_add_checkmark in test_cases: + with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) as count_ducks: + count_ducks.return_value = duck_count + with self.subTest(duck_count=duck_count, should_re_add_checkmark=should_re_add_checkmark): + await self.cog.on_raw_reaction_remove(payload) + + # Check if we fetched the message + channel.fetch_message.assert_called_once_with(message.id) + + # Check if we actually counted the number of ducks + count_ducks.assert_called_once_with(message) + + has_re_added_checkmark = message.add_reaction.called + self.assertEqual(should_re_add_checkmark, has_re_added_checkmark) + + if should_re_add_checkmark: + message.add_reaction.assert_called_once_with(self.checkmark_emoji) + message.add_reaction.reset_mock() + + # reset mocks + channel.fetch_message.reset_mock() + message.reset_mock() + + def test_on_raw_reaction_remove_ignores_removal_of_non_checkmark_reactions(self): + """The `on_raw_reaction_remove` listener should ignore the removal of non-check mark emojis.""" + channel = helpers.MockTextChannel(id=98765) + + channel.fetch_message.side_effect = AssertionError( + "Expected method to return before calling `channel.fetch_message`" + ) + + self.bot.get_all_channels.return_value = (channel, ) + + payload = MagicMock(emoji=helpers.MockPartialEmoji(name=self.thumbs_up_emoji), channel_id=channel.id) + + self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_remove(payload))) + + channel.fetch_message.assert_not_called() + + +class DuckPondSetupTests(unittest.TestCase): + """Tests setup of the `DuckPond` cog.""" + + def test_setup(self): + """Setup of the extension should call add_cog.""" + bot = helpers.MockBot() + duck_pond.setup(bot) + bot.add_cog.assert_called_once() diff --git a/tests/bot/exts/test_duck_pond.py b/tests/bot/exts/test_duck_pond.py deleted file mode 100644 index f6d977482..000000000 --- a/tests/bot/exts/test_duck_pond.py +++ /dev/null @@ -1,548 +0,0 @@ -import asyncio -import logging -import typing -import unittest -from unittest.mock import AsyncMock, MagicMock, patch - -import discord - -from bot import constants -from bot.exts import duck_pond -from tests import base -from tests import helpers - -MODULE_PATH = "bot.exts.duck_pond" - - -class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): - """Tests for DuckPond functionality.""" - - @classmethod - def setUpClass(cls): - """Sets up the objects that only have to be initialized once.""" - cls.nonstaff_member = helpers.MockMember(name="Non-staffer") - - cls.staff_role = helpers.MockRole(name="Staff role", id=constants.STAFF_ROLES[0]) - cls.staff_member = helpers.MockMember(name="staffer", roles=[cls.staff_role]) - - cls.checkmark_emoji = "\N{White Heavy Check Mark}" - cls.thumbs_up_emoji = "\N{Thumbs Up Sign}" - cls.unicode_duck_emoji = "\N{Duck}" - cls.duck_pond_emoji = helpers.MockPartialEmoji(id=constants.DuckPond.custom_emojis[0]) - cls.non_duck_custom_emoji = helpers.MockPartialEmoji(id=123) - - def setUp(self): - """Sets up the objects that need to be refreshed before each test.""" - self.bot = helpers.MockBot(user=helpers.MockMember(id=46692)) - self.cog = duck_pond.DuckPond(bot=self.bot) - - def test_duck_pond_correctly_initializes(self): - """`__init__ should set `bot` and `webhook_id` attributes and schedule `fetch_webhook`.""" - bot = helpers.MockBot() - cog = MagicMock() - - duck_pond.DuckPond.__init__(cog, bot) - - self.assertEqual(cog.bot, bot) - self.assertEqual(cog.webhook_id, constants.Webhooks.duck_pond) - bot.loop.create_task.assert_called_once_with(cog.fetch_webhook()) - - def test_fetch_webhook_succeeds_without_connectivity_issues(self): - """The `fetch_webhook` method waits until `READY` event and sets the `webhook` attribute.""" - self.bot.fetch_webhook.return_value = "dummy webhook" - self.cog.webhook_id = 1 - - asyncio.run(self.cog.fetch_webhook()) - - self.bot.wait_until_guild_available.assert_called_once() - self.bot.fetch_webhook.assert_called_once_with(1) - self.assertEqual(self.cog.webhook, "dummy webhook") - - def test_fetch_webhook_logs_when_unable_to_fetch_webhook(self): - """The `fetch_webhook` method should log an exception when it fails to fetch the webhook.""" - self.bot.fetch_webhook.side_effect = discord.HTTPException(response=MagicMock(), message="Not found.") - self.cog.webhook_id = 1 - - log = logging.getLogger('bot.exts.duck_pond') - with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: - asyncio.run(self.cog.fetch_webhook()) - - self.bot.wait_until_guild_available.assert_called_once() - self.bot.fetch_webhook.assert_called_once_with(1) - - self.assertEqual(len(log_watcher.records), 1) - - record = log_watcher.records[0] - self.assertEqual(record.levelno, logging.ERROR) - - def test_is_staff_returns_correct_values_based_on_instance_passed(self): - """The `is_staff` method should return correct values based on the instance passed.""" - test_cases = ( - (helpers.MockUser(name="User instance"), False), - (helpers.MockMember(name="Member instance without staff role"), False), - (helpers.MockMember(name="Member instance with staff role", roles=[self.staff_role]), True) - ) - - for user, expected_return in test_cases: - actual_return = self.cog.is_staff(user) - with self.subTest(user_type=user.name, expected_return=expected_return, actual_return=actual_return): - self.assertEqual(expected_return, actual_return) - - async def test_has_green_checkmark_correctly_detects_presence_of_green_checkmark_emoji(self): - """The `has_green_checkmark` method should only return `True` if one is present.""" - test_cases = ( - ( - "No reactions", helpers.MockMessage(), False - ), - ( - "No green check mark reactions", - helpers.MockMessage(reactions=[ - helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]), - helpers.MockReaction(emoji=self.thumbs_up_emoji, users=[self.bot.user]) - ]), - False - ), - ( - "Green check mark reaction, but not from our bot", - helpers.MockMessage(reactions=[ - helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]), - helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.staff_member]) - ]), - False - ), - ( - "Green check mark reaction, with one from the bot", - helpers.MockMessage(reactions=[ - helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]), - helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.staff_member, self.bot.user]) - ]), - True - ) - ) - - for description, message, expected_return in test_cases: - actual_return = await self.cog.has_green_checkmark(message) - with self.subTest( - test_case=description, - expected_return=expected_return, - actual_return=actual_return - ): - self.assertEqual(expected_return, actual_return) - - def _get_reaction( - self, - emoji: typing.Union[str, helpers.MockEmoji], - staff: int = 0, - nonstaff: int = 0 - ) -> helpers.MockReaction: - staffers = [helpers.MockMember(roles=[self.staff_role]) for _ in range(staff)] - nonstaffers = [helpers.MockMember() for _ in range(nonstaff)] - return helpers.MockReaction(emoji=emoji, users=staffers + nonstaffers) - - async def test_count_ducks_correctly_counts_the_number_of_eligible_duck_emojis(self): - """The `count_ducks` method should return the number of unique staffers who gave a duck.""" - test_cases = ( - # Simple test cases - # A message without reactions should return 0 - ( - "No reactions", - helpers.MockMessage(), - 0 - ), - # A message with a non-duck reaction from a non-staffer should return 0 - ( - "Non-duck reaction from non-staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.thumbs_up_emoji, nonstaff=1)]), - 0 - ), - # A message with a non-duck reaction from a staffer should return 0 - ( - "Non-duck reaction from staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.non_duck_custom_emoji, staff=1)]), - 0 - ), - # A message with a non-duck reaction from a non-staffer and staffer should return 0 - ( - "Non-duck reaction from staffer + non-staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.thumbs_up_emoji, staff=1, nonstaff=1)]), - 0 - ), - # A message with a unicode duck reaction from a non-staffer should return 0 - ( - "Unicode Duck Reaction from non-staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, nonstaff=1)]), - 0 - ), - # A message with a unicode duck reaction from a staffer should return 1 - ( - "Unicode Duck Reaction from staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, staff=1)]), - 1 - ), - # A message with a unicode duck reaction from a non-staffer and staffer should return 1 - ( - "Unicode Duck Reaction from staffer + non-staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, staff=1, nonstaff=1)]), - 1 - ), - # A message with a duckpond duck reaction from a non-staffer should return 0 - ( - "Duckpond Duck Reaction from non-staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, nonstaff=1)]), - 0 - ), - # A message with a duckpond duck reaction from a staffer should return 1 - ( - "Duckpond Duck Reaction from staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=1)]), - 1 - ), - # A message with a duckpond duck reaction from a non-staffer and staffer should return 1 - ( - "Duckpond Duck Reaction from staffer + non-staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=1, nonstaff=1)]), - 1 - ), - - # Complex test cases - # A message with duckpond duck reactions from 3 staffers and 2 non-staffers returns 3 - ( - "Duckpond Duck Reaction from 3 staffers + 2 non-staffers", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=3, nonstaff=2)]), - 3 - ), - # A staffer with multiple duck reactions only counts once - ( - "Two different duck reactions from the same staffer", - helpers.MockMessage( - reactions=[ - helpers.MockReaction(emoji=self.duck_pond_emoji, users=[self.staff_member]), - helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.staff_member]), - ] - ), - 1 - ), - # A non-string emoji does not count (to test the `isinstance(reaction.emoji, str)` elif) - ( - "Reaction with non-Emoji/str emoij from 3 staffers + 2 non-staffers", - helpers.MockMessage(reactions=[self._get_reaction(emoji=100, staff=3, nonstaff=2)]), - 0 - ), - # We correctly sum when multiple reactions are provided. - ( - "Duckpond Duck Reaction from 3 staffers + 2 non-staffers", - helpers.MockMessage( - reactions=[ - self._get_reaction(emoji=self.duck_pond_emoji, staff=3, nonstaff=2), - self._get_reaction(emoji=self.unicode_duck_emoji, staff=4, nonstaff=9), - ] - ), - 3 + 4 - ), - ) - - for description, message, expected_count in test_cases: - actual_count = await self.cog.count_ducks(message) - with self.subTest(test_case=description, expected_count=expected_count, actual_count=actual_count): - self.assertEqual(expected_count, actual_count) - - async def test_relay_message_correctly_relays_content_and_attachments(self): - """The `relay_message` method should correctly relay message content and attachments.""" - send_webhook_path = f"{MODULE_PATH}.send_webhook" - send_attachments_path = f"{MODULE_PATH}.send_attachments" - author = MagicMock( - display_name="x", - avatar_url="https://" - ) - - self.cog.webhook = helpers.MockAsyncWebhook() - - test_values = ( - (helpers.MockMessage(author=author, clean_content="", attachments=[]), False, False), - (helpers.MockMessage(author=author, clean_content="message", attachments=[]), True, False), - (helpers.MockMessage(author=author, clean_content="", attachments=["attachment"]), False, True), - (helpers.MockMessage(author=author, clean_content="message", attachments=["attachment"]), True, True), - ) - - for message, expect_webhook_call, expect_attachment_call in test_values: - with patch(send_webhook_path, new_callable=AsyncMock) as send_webhook: - with patch(send_attachments_path, new_callable=AsyncMock) as send_attachments: - with self.subTest(clean_content=message.clean_content, attachments=message.attachments): - await self.cog.relay_message(message) - - self.assertEqual(expect_webhook_call, send_webhook.called) - self.assertEqual(expect_attachment_call, send_attachments.called) - - message.add_reaction.assert_called_once_with(self.checkmark_emoji) - - @patch(f"{MODULE_PATH}.send_attachments", new_callable=AsyncMock) - async def test_relay_message_handles_irretrievable_attachment_exceptions(self, send_attachments): - """The `relay_message` method should handle irretrievable attachments.""" - message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) - side_effects = (discord.errors.Forbidden(MagicMock(), ""), discord.errors.NotFound(MagicMock(), "")) - - self.cog.webhook = helpers.MockAsyncWebhook() - log = logging.getLogger("bot.exts.duck_pond") - - for side_effect in side_effects: # pragma: no cover - send_attachments.side_effect = side_effect - with patch(f"{MODULE_PATH}.send_webhook", new_callable=AsyncMock) as send_webhook: - with self.subTest(side_effect=type(side_effect).__name__): - with self.assertNotLogs(logger=log, level=logging.ERROR): - await self.cog.relay_message(message) - - self.assertEqual(send_webhook.call_count, 2) - - @patch(f"{MODULE_PATH}.send_webhook", new_callable=AsyncMock) - @patch(f"{MODULE_PATH}.send_attachments", new_callable=AsyncMock) - async def test_relay_message_handles_attachment_http_error(self, send_attachments, send_webhook): - """The `relay_message` method should handle irretrievable attachments.""" - message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) - - self.cog.webhook = helpers.MockAsyncWebhook() - log = logging.getLogger("bot.exts.duck_pond") - - side_effect = discord.HTTPException(MagicMock(), "") - send_attachments.side_effect = side_effect - with self.subTest(side_effect=type(side_effect).__name__): - with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: - await self.cog.relay_message(message) - - send_webhook.assert_called_once_with( - webhook=self.cog.webhook, - content=message.clean_content, - username=message.author.display_name, - avatar_url=message.author.avatar_url - ) - - self.assertEqual(len(log_watcher.records), 1) - - record = log_watcher.records[0] - self.assertEqual(record.levelno, logging.ERROR) - - def _mock_payload(self, label: str, is_custom_emoji: bool, id_: int, emoji_name: str): - """Creates a mock `on_raw_reaction_add` payload with the specified emoji data.""" - payload = MagicMock(name=label) - payload.emoji.is_custom_emoji.return_value = is_custom_emoji - payload.emoji.id = id_ - payload.emoji.name = emoji_name - return payload - - async def test_payload_has_duckpond_emoji_correctly_detects_relevant_emojis(self): - """The `on_raw_reaction_add` event handler should ignore irrelevant emojis.""" - test_values = ( - # Custom Emojis - ( - self._mock_payload( - label="Custom Duckpond Emoji", - is_custom_emoji=True, - id_=constants.DuckPond.custom_emojis[0], - emoji_name="" - ), - True - ), - ( - self._mock_payload( - label="Custom Non-Duckpond Emoji", - is_custom_emoji=True, - id_=123, - emoji_name="" - ), - False - ), - # Unicode Emojis - ( - self._mock_payload( - label="Unicode Duck Emoji", - is_custom_emoji=False, - id_=1, - emoji_name=self.unicode_duck_emoji - ), - True - ), - ( - self._mock_payload( - label="Unicode Non-Duck Emoji", - is_custom_emoji=False, - id_=1, - emoji_name=self.thumbs_up_emoji - ), - False - ), - ) - - for payload, expected_return in test_values: - actual_return = self.cog._payload_has_duckpond_emoji(payload) - with self.subTest(case=payload._mock_name, expected_return=expected_return, actual_return=actual_return): - self.assertEqual(expected_return, actual_return) - - @patch(f"{MODULE_PATH}.discord.utils.get") - @patch(f"{MODULE_PATH}.DuckPond._payload_has_duckpond_emoji", new=MagicMock(return_value=False)) - def test_on_raw_reaction_add_returns_early_with_payload_without_duck_emoji(self, utils_get): - """The `on_raw_reaction_add` method should return early if the payload does not contain a duck emoji.""" - self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_add(payload=MagicMock()))) - - # Ensure we've returned before making an unnecessary API call in the lines of code after the emoji check - utils_get.assert_not_called() - - def _raw_reaction_mocks(self, channel_id, message_id, user_id): - """Sets up mocks for tests of the `on_raw_reaction_add` event listener.""" - channel = helpers.MockTextChannel(id=channel_id) - self.bot.get_all_channels.return_value = (channel,) - - message = helpers.MockMessage(id=message_id) - - channel.fetch_message.return_value = message - - member = helpers.MockMember(id=user_id, roles=[self.staff_role]) - message.guild.members = (member,) - - payload = MagicMock(channel_id=channel_id, message_id=message_id, user_id=user_id) - - return channel, message, member, payload - - async def test_on_raw_reaction_add_returns_for_bot_and_non_staff_members(self): - """The `on_raw_reaction_add` event handler should return for bot users or non-staff members.""" - channel_id = 1234 - message_id = 2345 - user_id = 3456 - - channel, message, _, payload = self._raw_reaction_mocks(channel_id, message_id, user_id) - - test_cases = ( - ("non-staff member", helpers.MockMember(id=user_id)), - ("bot staff member", helpers.MockMember(id=user_id, roles=[self.staff_role], bot=True)), - ) - - payload.emoji = self.duck_pond_emoji - - for description, member in test_cases: - message.guild.members = (member, ) - with self.subTest(test_case=description), patch(f"{MODULE_PATH}.DuckPond.has_green_checkmark") as checkmark: - checkmark.side_effect = AssertionError( - "Expected method to return before calling `self.has_green_checkmark`." - ) - self.assertIsNone(await self.cog.on_raw_reaction_add(payload)) - - # Check that we did make it past the payload checks - channel.fetch_message.assert_called_once() - channel.fetch_message.reset_mock() - - @patch(f"{MODULE_PATH}.DuckPond.is_staff") - @patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) - def test_on_raw_reaction_add_returns_on_message_with_green_checkmark_placed_by_bot(self, count_ducks, is_staff): - """The `on_raw_reaction_add` event should return when the message has a green check mark placed by the bot.""" - channel_id = 31415926535 - message_id = 27182818284 - user_id = 16180339887 - - channel, message, member, payload = self._raw_reaction_mocks(channel_id, message_id, user_id) - - payload.emoji = helpers.MockPartialEmoji(name=self.unicode_duck_emoji) - payload.emoji.is_custom_emoji.return_value = False - - message.reactions = [helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.bot.user])] - - is_staff.return_value = True - count_ducks.side_effect = AssertionError("Expected method to return before calling `self.count_ducks`") - - self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_add(payload))) - - # Assert that we've made it past `self.is_staff` - is_staff.assert_called_once() - - async def test_on_raw_reaction_add_does_not_relay_below_duck_threshold(self): - """The `on_raw_reaction_add` listener should not relay messages or attachments below the duck threshold.""" - test_cases = ( - (constants.DuckPond.threshold - 1, False), - (constants.DuckPond.threshold, True), - (constants.DuckPond.threshold + 1, True), - ) - - channel, message, member, payload = self._raw_reaction_mocks(channel_id=3, message_id=4, user_id=5) - - payload.emoji = self.duck_pond_emoji - - for duck_count, should_relay in test_cases: - with patch(f"{MODULE_PATH}.DuckPond.relay_message", new_callable=AsyncMock) as relay_message: - with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) as count_ducks: - count_ducks.return_value = duck_count - with self.subTest(duck_count=duck_count, should_relay=should_relay): - await self.cog.on_raw_reaction_add(payload) - - # Confirm that we've made it past counting - count_ducks.assert_called_once() - - # Did we relay a message? - has_relayed = relay_message.called - self.assertEqual(has_relayed, should_relay) - - if should_relay: - relay_message.assert_called_once_with(message) - - async def test_on_raw_reaction_remove_prevents_removal_of_green_checkmark_depending_on_the_duck_count(self): - """The `on_raw_reaction_remove` listener prevents removal of the check mark on messages with enough ducks.""" - checkmark = helpers.MockPartialEmoji(name=self.checkmark_emoji) - - message = helpers.MockMessage(id=1234) - - channel = helpers.MockTextChannel(id=98765) - channel.fetch_message.return_value = message - - self.bot.get_all_channels.return_value = (channel, ) - - payload = MagicMock(channel_id=channel.id, message_id=message.id, emoji=checkmark) - - test_cases = ( - (constants.DuckPond.threshold - 1, False), - (constants.DuckPond.threshold, True), - (constants.DuckPond.threshold + 1, True), - ) - for duck_count, should_re_add_checkmark in test_cases: - with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) as count_ducks: - count_ducks.return_value = duck_count - with self.subTest(duck_count=duck_count, should_re_add_checkmark=should_re_add_checkmark): - await self.cog.on_raw_reaction_remove(payload) - - # Check if we fetched the message - channel.fetch_message.assert_called_once_with(message.id) - - # Check if we actually counted the number of ducks - count_ducks.assert_called_once_with(message) - - has_re_added_checkmark = message.add_reaction.called - self.assertEqual(should_re_add_checkmark, has_re_added_checkmark) - - if should_re_add_checkmark: - message.add_reaction.assert_called_once_with(self.checkmark_emoji) - message.add_reaction.reset_mock() - - # reset mocks - channel.fetch_message.reset_mock() - message.reset_mock() - - def test_on_raw_reaction_remove_ignores_removal_of_non_checkmark_reactions(self): - """The `on_raw_reaction_remove` listener should ignore the removal of non-check mark emojis.""" - channel = helpers.MockTextChannel(id=98765) - - channel.fetch_message.side_effect = AssertionError( - "Expected method to return before calling `channel.fetch_message`" - ) - - self.bot.get_all_channels.return_value = (channel, ) - - payload = MagicMock(emoji=helpers.MockPartialEmoji(name=self.thumbs_up_emoji), channel_id=channel.id) - - self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_remove(payload))) - - channel.fetch_message.assert_not_called() - - -class DuckPondSetupTests(unittest.TestCase): - """Tests setup of the `DuckPond` cog.""" - - def test_setup(self): - """Setup of the extension should call add_cog.""" - bot = helpers.MockBot() - duck_pond.setup(bot) - bot.add_cog.assert_called_once() -- cgit v1.2.3 From 743a6b434000813425bf6480b9f7788043f6115d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 19 Aug 2020 13:46:17 -0700 Subject: Swap argument order in ChainMaps The defaults should be last to ensure they don't take precedence over explicitly set values. --- bot/cogs/watchchannels/bigbrother.py | 2 +- bot/cogs/watchchannels/talentpool.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 4d27a6333..7aa9cec58 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -131,8 +131,8 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): active_watches = await self.bot.api_client.get( self.api_endpoint, params=ChainMap( + {"user__id": str(user.id)}, self.api_default_params, - {"user__id": str(user.id)} ) ) if active_watches: diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 002f01399..c5621ae18 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -216,8 +216,8 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): active_nomination = await self.bot.api_client.get( self.api_endpoint, params=ChainMap( + {"user__id": str(user_id)}, self.api_default_params, - {"user__id": str(user_id)} ) ) -- cgit v1.2.3 From bca71687eec90b88e60155679d369b57344a0ddc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 19 Aug 2020 13:50:21 -0700 Subject: Replace stinky single-item unpacking syntax --- bot/cogs/watchchannels/talentpool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index c5621ae18..a6df84c23 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -227,7 +227,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): log.info(f"Ending nomination: {user_id=} {reason=}") - [nomination] = active_nomination + nomination = active_nomination[0] await self.bot.api_client.patch( f"{self.api_endpoint}/{nomination['id']}", json={'end_reason': reason, 'active': False} -- cgit v1.2.3 From 574bcac2b3fb43fc74a6c840667cfed408bc4077 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 20 Aug 2020 13:53:54 +0200 Subject: Restrict reminder methods to authors and admins. Before, any user could modify the reminders of others by the id. This restricts the behaviour to only admins and users can only modify the reminders they authored. --- bot/cogs/reminders.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 670493bcf..08bce2153 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -12,10 +12,10 @@ from dateutil.relativedelta import relativedelta from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot -from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, STAFF_ROLES +from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Roles, STAFF_ROLES from bot.converters import Duration from bot.pagination import LinePaginator -from bot.utils.checks import without_role_check +from bot.utils.checks import with_role_check, without_role_check from bot.utils.messages import send_denial from bot.utils.scheduling import Scheduler from bot.utils.time import humanize_delta @@ -396,6 +396,8 @@ class Reminders(Cog): async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: """Edits a reminder with the given payload, then sends a confirmation message.""" + if not await self._can_modify(ctx, id_): + return reminder = await self._edit_reminder(id_, payload) # Parse the reminder expiration back into a datetime @@ -413,6 +415,8 @@ class Reminders(Cog): @remind_group.command("delete", aliases=("remove", "cancel")) async def delete_reminder(self, ctx: Context, id_: int) -> None: """Delete one of your active reminders.""" + if not await self._can_modify(ctx, id_): + return await self._delete_reminder(id_) await self._send_confirmation( ctx, @@ -421,6 +425,24 @@ class Reminders(Cog): delivery_dt=None, ) + async def _can_modify(self, ctx: Context, reminder_id: t.Union[str, int]) -> bool: + """ + Check whether the reminder can be modified by the ctx author. + + The check passes when the user is an admin, or if they created the reminder. + """ + if with_role_check(ctx, Roles.admins): + return True + + api_response = await self.bot.api_client.get(f"bot/reminders/{reminder_id}") + if not api_response["author"] == ctx.author.id: + log.debug(f"{ctx.author} is not the reminder author and does not pass the check.") + await send_denial(ctx, "You can't modify reminders of other users!") + return False + + log.debug(f"{ctx.author} is the reminder author and passes the check.") + return True + def setup(bot: Bot) -> None: """Load the Reminders cog.""" -- cgit v1.2.3 From 47521608d573c97597df7b97bf42b0142f79e98c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 20 Aug 2020 11:02:40 -0700 Subject: Make client parameter mandatory for wait_for_deletion A client instance is necessary for the core feature of this function. There is no way to obtain it from the other arguments. The previous code was wrong to think `discord.Guild.me` is an equivalent. Fixes #1112 --- bot/cogs/bot.py | 2 +- bot/cogs/snekbox.py | 4 +--- bot/cogs/tags.py | 4 ++-- bot/utils/messages.py | 13 ++++--------- 4 files changed, 8 insertions(+), 15 deletions(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index 79510739c..70ef407d7 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -337,7 +337,7 @@ class BotCog(Cog, name="Bot"): self.codeblock_message_ids[msg.id] = bot_message.id self.bot.loop.create_task( - wait_for_deletion(bot_message, user_ids=(msg.author.id,), client=self.bot) + wait_for_deletion(bot_message, (msg.author.id,), self.bot) ) else: return diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 52c8b6f88..63e6d7f31 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -220,9 +220,7 @@ class Snekbox(Cog): response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.") else: response = await ctx.send(msg) - self.bot.loop.create_task( - wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot) - ) + self.bot.loop.create_task(wait_for_deletion(response, (ctx.author.id,), ctx.bot)) log.info(f"{ctx.author}'s job had a return code of {results['returncode']}") return response diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 3d76c5c08..d01647312 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -236,7 +236,7 @@ class Tags(Cog): await wait_for_deletion( await ctx.send(embed=Embed.from_dict(tag['embed'])), [ctx.author.id], - client=self.bot + self.bot ) elif founds and len(tag_name) >= 3: await wait_for_deletion( @@ -247,7 +247,7 @@ class Tags(Cog): ) ), [ctx.author.id], - client=self.bot + self.bot ) else: diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 670289941..aa8f17f75 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -19,25 +19,20 @@ log = logging.getLogger(__name__) async def wait_for_deletion( message: Message, user_ids: Sequence[Snowflake], + client: Client, deletion_emojis: Sequence[str] = (Emojis.trashcan,), timeout: float = 60 * 5, attach_emojis: bool = True, - client: Optional[Client] = None ) -> None: """ Wait for up to `timeout` seconds for a reaction by any of the specified `user_ids` to delete the message. An `attach_emojis` bool may be specified to determine whether to attach the given - `deletion_emojis` to the message in the given `context` - - A `client` instance may be optionally specified, otherwise client will be taken from the - guild of the message. + `deletion_emojis` to the message in the given `context`. """ - if message.guild is None and client is None: + if message.guild is None: raise ValueError("Message must be sent on a guild") - bot = client or message.guild.me - if attach_emojis: for emoji in deletion_emojis: await message.add_reaction(emoji) @@ -51,7 +46,7 @@ async def wait_for_deletion( ) with contextlib.suppress(asyncio.TimeoutError): - await bot.wait_for('reaction_add', check=check, timeout=timeout) + await client.wait_for('reaction_add', check=check, timeout=timeout) await message.delete() -- cgit v1.2.3 From e0438b2f78ffbc22a9d4d391db524563ec9baa18 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 20 Aug 2020 11:16:18 -0700 Subject: Watchchannels: censor message content if it has a leaked token Fixes #1094 --- bot/cogs/watchchannels/watchchannel.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 044077350..a58b604c0 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -15,6 +15,8 @@ from discord.ext.commands import Cog, Context from bot.api import ResponseCodeError from bot.bot import Bot from bot.cogs.moderation import ModLog +from bot.cogs.token_remover import TokenRemover +from bot.cogs.webhook_remover import WEBHOOK_URL_RE from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons from bot.pagination import LinePaginator from bot.utils import CogABCMeta, messages @@ -226,14 +228,16 @@ class WatchChannel(metaclass=CogABCMeta): await self.send_header(msg) - cleaned_content = msg.clean_content - - if cleaned_content: + if TokenRemover.find_token_in_message(msg) or WEBHOOK_URL_RE.search(msg.content): + cleaned_content = "Content is censored because it contains a bot or webhook token." + elif cleaned_content := msg.clean_content: # Put all non-media URLs in a code block to prevent embeds media_urls = {embed.url for embed in msg.embeds if embed.type in ("image", "video")} for url in URL_RE.findall(cleaned_content): if url not in media_urls: cleaned_content = cleaned_content.replace(url, f"`{url}`") + + if cleaned_content: await self.webhook_send( cleaned_content, username=msg.author.display_name, -- cgit v1.2.3 From c0afea19897ec0b47642bb62e4a426f4ca0c3cc8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 20 Aug 2020 11:18:02 -0700 Subject: Don't send code block help if message has a webhook token --- bot/cogs/bot.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index 79510739c..93f2eae7c 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -9,6 +9,7 @@ from discord.ext.commands import Cog, Context, command, group from bot.bot import Bot from bot.cogs.token_remover import TokenRemover +from bot.cogs.webhook_remover import WEBHOOK_URL_RE from bot.constants import Categories, Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs from bot.decorators import with_role from bot.utils.messages import wait_for_deletion @@ -240,6 +241,7 @@ class BotCog(Cog, name="Bot"): and not msg.author.bot and len(msg.content.splitlines()) > 3 and not TokenRemover.find_token_in_message(msg) + and not WEBHOOK_URL_RE.search(msg.content) ) if parse_codeblock: # no token in the msg -- cgit v1.2.3 From 36ccac8272de9e60c1c04db7ab3640fd76af8585 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Thu, 20 Aug 2020 22:35:12 +0100 Subject: Disable raw commands --- bot/cogs/information.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 8982196d1..2d87866fb 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -376,7 +376,7 @@ class Information(Cog): return out.rstrip() @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES) - @group(invoke_without_command=True) + @group(invoke_without_command=True, enabled=False) @in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_ROLES) async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None: """Shows information about the raw API response.""" @@ -411,7 +411,7 @@ class Information(Cog): for page in paginator.pages: await ctx.send(page) - @raw.command() + @raw.command(enabled=False) async def json(self, ctx: Context, message: Message) -> None: """Shows information about the raw API response in a copy-pasteable Python format.""" await ctx.invoke(self.raw, message=message, json=True) -- cgit v1.2.3 From 59a58db3ca6ba14539b028f3e02ccc4d89ec16a0 Mon Sep 17 00:00:00 2001 From: AtieP Date: Sat, 22 Aug 2020 18:38:05 +0200 Subject: Use wait_for_deletion from bot/utils/messages.py rather than help_cleanup --- bot/cogs/help.py | 31 +++++-------------------------- 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 3d1d6fd10..76aaf655c 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -1,11 +1,10 @@ import itertools import logging -from asyncio import TimeoutError from collections import namedtuple from contextlib import suppress from typing import List, Union -from discord import Colour, Embed, Member, Message, NotFound, Reaction, User +from discord import Colour, Embed from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand from fuzzywuzzy import fuzz, process from fuzzywuzzy.utils import full_process @@ -14,6 +13,7 @@ from bot import constants from bot.constants import Channels, Emojis, STAFF_ROLES from bot.decorators import redirect_output from bot.pagination import LinePaginator +from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) @@ -24,27 +24,6 @@ PREFIX = constants.Bot.prefix Category = namedtuple("Category", ["name", "description", "cogs"]) -async def help_cleanup(bot: Bot, author: Member, message: Message) -> None: - """ - Runs the cleanup for the help command. - - Adds the :trashcan: reaction that, when clicked, will delete the help message. - After a 300 second timeout, the reaction will be removed. - """ - def check(reaction: Reaction, user: User) -> bool: - """Checks the reaction is :trashcan:, the author is original author and messages are the same.""" - return str(reaction) == DELETE_EMOJI and user.id == author.id and reaction.message.id == message.id - - await message.add_reaction(DELETE_EMOJI) - - with suppress(NotFound): - try: - await bot.wait_for("reaction_add", check=check, timeout=300) - await message.delete() - except TimeoutError: - await message.remove_reaction(DELETE_EMOJI, bot.user) - - class HelpQueryNotFound(ValueError): """ Raised when a HelpSession Query doesn't match a command or cog. @@ -206,7 +185,7 @@ class CustomHelpCommand(HelpCommand): """Send help for a single command.""" embed = await self.command_formatting(command) message = await self.context.send(embed=embed) - await help_cleanup(self.context.bot, self.context.author, message) + await wait_for_deletion(message, (self.context.author.id,), self.context.bot) @staticmethod def get_commands_brief_details(commands_: List[Command], return_as_list: bool = False) -> Union[List[str], str]: @@ -245,7 +224,7 @@ class CustomHelpCommand(HelpCommand): embed.description += f"\n**Subcommands:**\n{command_details}" message = await self.context.send(embed=embed) - await help_cleanup(self.context.bot, self.context.author, message) + await wait_for_deletion(message, (self.context.author.id,), self.context.bot) async def send_cog_help(self, cog: Cog) -> None: """Send help for a cog.""" @@ -261,7 +240,7 @@ class CustomHelpCommand(HelpCommand): embed.description += f"\n\n**Commands:**\n{command_details}" message = await self.context.send(embed=embed) - await help_cleanup(self.context.bot, self.context.author, message) + await wait_for_deletion(message, (self.context.author.id,), self.context.bot) @staticmethod def _category_key(command: Command) -> str: -- cgit v1.2.3 From 2544d192fd7403cf92cc568537bb93aa7a859815 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Aug 2020 09:50:25 -0700 Subject: Decorators: replace asyncio.Lock with a custom object Concerns were raised over possible race conditions due `asyncio.Lock` internally awaiting coroutines. Does a mere `await` suspend the current coroutine, or does it have to actually await something asynchronous, like a future? Avoid answering that question by doing away with the awaits, which aren't necessary but are there as a consequence of using `asyncio.Lock`. Instead, add a custom `LockGuard` object to replace the previous locks. --- bot/decorators.py | 6 +++--- bot/utils/__init__.py | 3 ++- bot/utils/lock.py | 23 +++++++++++++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 bot/utils/lock.py diff --git a/bot/decorators.py b/bot/decorators.py index 0e84cf37e..3418dfd11 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -13,7 +13,7 @@ from discord.ext.commands import Cog, Context, check from bot.constants import Channels, ERROR_REPLIES, RedirectOutput from bot.errors import LockedResourceError -from bot.utils import function +from bot.utils import LockGuard, function from bot.utils.checks import in_whitelist_check, with_role_check, without_role_check log = logging.getLogger(__name__) @@ -144,11 +144,11 @@ def mutually_exclusive( # Get the lock for the ID. Create a lock if one doesn't exist yet. locks = __lock_dicts[namespace] - lock = locks.setdefault(id_, asyncio.Lock()) + lock = locks.setdefault(id_, LockGuard()) if not lock.locked(): log.debug(f"{name}: resource {namespace!r}:{id_!r} is free; acquiring it...") - async with lock: + with lock: return await func(*args, **kwargs) else: log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked") diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 5a6e1811b..0dd9605e8 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -2,9 +2,10 @@ from abc import ABCMeta from discord.ext.commands import CogMeta +from bot.utils.lock import LockGuard from bot.utils.redis_cache import RedisCache -__all__ = ['RedisCache', 'CogABCMeta'] +__all__ = ["CogABCMeta", "LockGuard", "RedisCache"] class CogABCMeta(CogMeta, ABCMeta): diff --git a/bot/utils/lock.py b/bot/utils/lock.py new file mode 100644 index 000000000..8f1b738aa --- /dev/null +++ b/bot/utils/lock.py @@ -0,0 +1,23 @@ +class LockGuard: + """ + A context manager which acquires and releases a lock (mutex). + + Raise RuntimeError if trying to acquire a locked lock. + """ + + def __init__(self): + self._locked = False + + def locked(self) -> bool: + """Return True if currently locked or False if unlocked.""" + return self._locked + + def __enter__(self): + if self._locked: + raise RuntimeError("Cannot acquire a locked lock.") + + self._locked = True + + def __exit__(self, _exc_type, _exc_value, _traceback): # noqa: ANN001 + self._locked = False + return False # Indicate any raised exception shouldn't be suppressed. -- cgit v1.2.3 From ee4efbb91300890424d1f8ecb1273166e9f0f53a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Aug 2020 13:05:49 -0700 Subject: Define a Command subclass with root alias support A subclass is used because cogs make copies of Command objects. They do this to allow multiple instances of a cog to be used. If the Command class doesn't inherently support the `root_aliases` kwarg, it won't end up being copied when a command gets copied. `Command.__original_kwargs__` could be updated to include the new kwarg. However, updating it and adding the attribute to the command wouldn't be as elegant as passing a `Command` subclass as a `cls` attribute to the `commands.command` decorator. This is because the former requires copying the entire code of the decorator to add the two lines into the nested function (it's a decorator with args, hence the nested function). --- bot/command.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 bot/command.py diff --git a/bot/command.py b/bot/command.py new file mode 100644 index 000000000..92e61d97e --- /dev/null +++ b/bot/command.py @@ -0,0 +1,15 @@ +from discord.ext import commands + + +class Command(commands.Command): + """ + A `discord.ext.commands.Command` subclass which supports root aliases. + + A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as + top-level commands rather than being aliases of the command's group. It's stored as an attribute + also named `root_aliases`. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.root_aliases = kwargs.get("root_aliases", []) -- cgit v1.2.3 From f455a7908a9b07747db6ab89f9c5c53bd5ea2450 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Aug 2020 19:13:21 -0700 Subject: Bot: add root alias support Override `Bot.add_command` and `Bot.remove_command` to add/remove root aliases for a command (and recursively for any subcommands). This has to happen in `Bot` because there's no reliable way to get the `Bot` instance otherwise. Therefore, overriding the methods in `GroupMixin` unfortunately doesn't work. Otherwise, it'd be possible to avoid recursion by processing each subcommand as it got added. --- bot/bot.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/bot/bot.py b/bot/bot.py index 756449293..34254d8e8 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -130,6 +130,26 @@ class Bot(commands.Bot): super().add_cog(cog) log.info(f"Cog loaded: {cog.qualified_name}") + def add_command(self, command: commands.Command) -> None: + """Add `command` as normal and then add its root aliases to the bot.""" + super().add_command(command) + self._add_root_aliases(command) + + def remove_command(self, name: str) -> Optional[commands.Command]: + """ + Remove a command/alias as normal and then remove its root aliases from the bot. + + Individual root aliases cannot be removed by this function. + To remove them, either remove the entire command or manually edit `bot.all_commands`. + """ + command = super().remove_command(name) + if command is None: + # Even if it's a root alias, there's no way to get the Bot instance to remove the alias. + return + + self._remove_root_aliases(command) + return command + def clear(self) -> None: """ Clears the internal state of the bot and recreates the connector and sessions. @@ -235,3 +255,24 @@ class Bot(commands.Bot): scope.set_extra("kwargs", kwargs) log.exception(f"Unhandled exception in {event}.") + + def _add_root_aliases(self, command: commands.Command) -> None: + """Recursively add root aliases for `command` and any of its subcommands.""" + if isinstance(command, commands.Group): + for subcommand in command.commands: + self._add_root_aliases(subcommand) + + for alias in command.root_aliases: + if alias in self.all_commands: + raise commands.CommandRegistrationError(alias, alias_conflict=True) + + self.all_commands[alias] = command + + def _remove_root_aliases(self, command: commands.Command) -> None: + """Recursively remove root aliases for `command` and any of its subcommands.""" + if isinstance(command, commands.Group): + for subcommand in command.commands: + self._remove_root_aliases(subcommand) + + for alias in command.root_aliases: + self.all_commands.pop(alias, None) -- cgit v1.2.3 From 36ec4b31730ac7243fc76fe5140a0ed2e922940f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Aug 2020 19:20:54 -0700 Subject: Patch d.py decorators to support root aliases To avoid explicitly specifying `cls` everywhere, patch the decorators to set the default value of `cls` to the `Command` subclass which supports root aliases. --- bot/__init__.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bot/__init__.py b/bot/__init__.py index d63086fe2..3ee70c4e9 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -2,10 +2,14 @@ import asyncio import logging import os import sys +from functools import partial, partialmethod from logging import Logger, handlers from pathlib import Path import coloredlogs +from discord.ext import commands + +from bot.command import Command TRACE_LEVEL = logging.TRACE = 5 logging.addLevelName(TRACE_LEVEL, "TRACE") @@ -66,3 +70,9 @@ logging.getLogger(__name__) # On Windows, the selector event loop is required for aiodns. if os.name == "nt": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + +# Monkey-patch discord.py decorators to use the Command subclass which supports root aliases. +# Must be patched before any cogs are added. +commands.command = partial(commands.command, cls=Command) +commands.GroupMixin.command = partialmethod(commands.GroupMixin.command, cls=Command) -- cgit v1.2.3 From 027ce8c5525187296a9f7bd26b89af9c66200835 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Aug 2020 19:43:15 -0700 Subject: Bot: fix AttributeError for commands which lack root_aliases Even if the `command` decorators are patched, there are still some other internal things that need to be patched. For example, the default help command subclasses the original `Command` type. It's more maintainable to exclude root alias support for these objects than to try to patch everything. --- bot/bot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 34254d8e8..d25074fd9 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -262,7 +262,7 @@ class Bot(commands.Bot): for subcommand in command.commands: self._add_root_aliases(subcommand) - for alias in command.root_aliases: + for alias in getattr(command, "root_aliases", ()): if alias in self.all_commands: raise commands.CommandRegistrationError(alias, alias_conflict=True) @@ -274,5 +274,5 @@ class Bot(commands.Bot): for subcommand in command.commands: self._remove_root_aliases(subcommand) - for alias in command.root_aliases: + for alias in getattr(command, "root_aliases", ()): self.all_commands.pop(alias, None) -- cgit v1.2.3 From c6a20ef3b7b7afe3013a17042f8f2ca84566d998 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Aug 2020 20:36:27 -0700 Subject: Replace alias command definitions with root_aliases The fruits of my labour. --- bot/cogs/alias.py | 70 ++---------------------------------- bot/cogs/defcon.py | 4 +-- bot/cogs/extensions.py | 2 +- bot/cogs/site.py | 10 +++--- bot/cogs/watchchannels/bigbrother.py | 4 +-- bot/cogs/watchchannels/talentpool.py | 6 ++-- 6 files changed, 15 insertions(+), 81 deletions(-) diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 55c7efe65..c6ba8d6f3 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -3,13 +3,12 @@ import logging from discord import Colour, Embed from discord.ext.commands import ( - Cog, Command, Context, Greedy, + Cog, Command, Context, clean_content, command, group, ) from bot.bot import Bot -from bot.cogs.extensions import Extension -from bot.converters import FetchedMember, TagNameConverter +from bot.converters import TagNameConverter from bot.pagination import LinePaginator log = logging.getLogger(__name__) @@ -51,56 +50,6 @@ class Alias (Cog): ctx, embed, empty=False, max_lines=20 ) - @command(name="resources", aliases=("resource",), hidden=True) - async def site_resources_alias(self, ctx: Context) -> None: - """Alias for invoking site resources.""" - await self.invoke(ctx, "site resources") - - @command(name="tools", hidden=True) - async def site_tools_alias(self, ctx: Context) -> None: - """Alias for invoking site tools.""" - await self.invoke(ctx, "site tools") - - @command(name="watch", hidden=True) - async def bigbrother_watch_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """Alias for invoking bigbrother watch [user] [reason].""" - await self.invoke(ctx, "bigbrother watch", user, reason=reason) - - @command(name="unwatch", hidden=True) - async def bigbrother_unwatch_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """Alias for invoking bigbrother unwatch [user] [reason].""" - await self.invoke(ctx, "bigbrother unwatch", user, reason=reason) - - @command(name="home", hidden=True) - async def site_home_alias(self, ctx: Context) -> None: - """Alias for invoking site home.""" - await self.invoke(ctx, "site home") - - @command(name="faq", hidden=True) - async def site_faq_alias(self, ctx: Context) -> None: - """Alias for invoking site faq.""" - await self.invoke(ctx, "site faq") - - @command(name="rules", aliases=("rule",), hidden=True) - async def site_rules_alias(self, ctx: Context, rules: Greedy[int], *_: str) -> None: - """Alias for invoking site rules.""" - await self.invoke(ctx, "site rules", *rules) - - @command(name="reload", hidden=True) - async def extensions_reload_alias(self, ctx: Context, *extensions: Extension) -> None: - """Alias for invoking extensions reload [extensions...].""" - await self.invoke(ctx, "extensions reload", *extensions) - - @command(name="defon", hidden=True) - async def defcon_enable_alias(self, ctx: Context) -> None: - """Alias for invoking defcon enable.""" - await self.invoke(ctx, "defcon enable") - - @command(name="defoff", hidden=True) - async def defcon_disable_alias(self, ctx: Context) -> None: - """Alias for invoking defcon disable.""" - await self.invoke(ctx, "defcon disable") - @command(name="exception", hidden=True) async def tags_get_traceback_alias(self, ctx: Context) -> None: """Alias for invoking tags get traceback.""" @@ -132,21 +81,6 @@ class Alias (Cog): """Alias for invoking docs get [symbol].""" await self.invoke(ctx, "docs get", symbol) - @command(name="nominate", hidden=True) - async def nomination_add_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """Alias for invoking talentpool add [user] [reason].""" - await self.invoke(ctx, "talentpool add", user, reason=reason) - - @command(name="unnominate", hidden=True) - async def nomination_end_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """Alias for invoking nomination end [user] [reason].""" - await self.invoke(ctx, "nomination end", user, reason=reason) - - @command(name="nominees", hidden=True) - async def nominees_alias(self, ctx: Context) -> None: - """Alias for invoking tp watched.""" - await self.invoke(ctx, "talentpool watched") - def setup(bot: Bot) -> None: """Load the Alias cog.""" diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 4c0ad5914..de0f4545e 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -162,7 +162,7 @@ class Defcon(Cog): self.bot.stats.gauge("defcon.threshold", days) - @defcon_group.command(name='enable', aliases=('on', 'e')) + @defcon_group.command(name='enable', aliases=('on', 'e'), root_aliases=("defon",)) @with_role(Roles.admins, Roles.owners) async def enable_command(self, ctx: Context) -> None: """ @@ -175,7 +175,7 @@ class Defcon(Cog): await self._defcon_action(ctx, days=0, action=Action.ENABLED) await self.update_channel_topic() - @defcon_group.command(name='disable', aliases=('off', 'd')) + @defcon_group.command(name='disable', aliases=('off', 'd'), root_aliases=("defoff",)) @with_role(Roles.admins, Roles.owners) async def disable_command(self, ctx: Context) -> None: """Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!""" diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index 365f198ff..396e406b0 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -107,7 +107,7 @@ class Extensions(commands.Cog): await ctx.send(msg) - @extensions_group.command(name="reload", aliases=("r",)) + @extensions_group.command(name="reload", aliases=("r",), root_aliases=("reload",)) async def reload_command(self, ctx: Context, *extensions: Extension) -> None: r""" Reload extensions given their fully qualified or unqualified names. diff --git a/bot/cogs/site.py b/bot/cogs/site.py index ac29daa1d..2d3a3d9f3 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -23,7 +23,7 @@ class Site(Cog): """Commands for getting info about our website.""" await ctx.send_help(ctx.command) - @site_group.command(name="home", aliases=("about",)) + @site_group.command(name="home", aliases=("about",), root_aliases=("home",)) async def site_main(self, ctx: Context) -> None: """Info about the website itself.""" url = f"{URLs.site_schema}{URLs.site}/" @@ -40,7 +40,7 @@ class Site(Cog): await ctx.send(embed=embed) - @site_group.command(name="resources") + @site_group.command(name="resources", root_aliases=("resources", "resource")) async def site_resources(self, ctx: Context) -> None: """Info about the site's Resources page.""" learning_url = f"{PAGES_URL}/resources" @@ -56,7 +56,7 @@ class Site(Cog): await ctx.send(embed=embed) - @site_group.command(name="tools") + @site_group.command(name="tools", root_aliases=("tools",)) async def site_tools(self, ctx: Context) -> None: """Info about the site's Tools page.""" tools_url = f"{PAGES_URL}/resources/tools" @@ -87,7 +87,7 @@ class Site(Cog): await ctx.send(embed=embed) - @site_group.command(name="faq") + @site_group.command(name="faq", root_aliases=("faq",)) async def site_faq(self, ctx: Context) -> None: """Info about the site's FAQ page.""" url = f"{PAGES_URL}/frequently-asked-questions" @@ -104,7 +104,7 @@ class Site(Cog): await ctx.send(embed=embed) - @site_group.command(aliases=['r', 'rule'], name='rules') + @site_group.command(name="rules", aliases=("r", "rule"), root_aliases=("rules", "rule")) async def site_rules(self, ctx: Context, *rules: int) -> None: """Provides a link to all rules or, if specified, displays specific rule(s).""" rules_embed = Embed(title='Rules', color=Colour.blurple()) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 7aa9cec58..11ab8917a 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -59,7 +59,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): """ await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) - @bigbrother_group.command(name='watch', aliases=('w',)) + @bigbrother_group.command(name='watch', aliases=('w',), root_aliases=('watch',)) @with_role(*MODERATION_ROLES) async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """ @@ -70,7 +70,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): """ await self.apply_watch(ctx, user, reason) - @bigbrother_group.command(name='unwatch', aliases=('uw',)) + @bigbrother_group.command(name='unwatch', aliases=('uw',), root_aliases=('unwatch',)) @with_role(*MODERATION_ROLES) async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """Stop relaying messages by the given `user`.""" diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index a6df84c23..76d6fe9bd 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -37,7 +37,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" await ctx.send_help(ctx.command) - @nomination_group.command(name='watched', aliases=('all', 'list')) + @nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",)) @with_role(*MODERATION_ROLES) async def watched_command( self, ctx: Context, oldest_first: bool = False, update_cache: bool = True @@ -63,7 +63,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): """ await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) - @nomination_group.command(name='watch', aliases=('w', 'add', 'a')) + @nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",)) @with_role(*STAFF_ROLES) async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """ @@ -157,7 +157,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): max_size=1000 ) - @nomination_group.command(name='unwatch', aliases=('end', )) + @nomination_group.command(name='unwatch', aliases=('end', ), root_aliases=("unnominate",)) @with_role(*MODERATION_ROLES) async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """ -- cgit v1.2.3 From 520ac0f9871bf6775d76eea753ed2a940704e92d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Aug 2020 20:44:48 -0700 Subject: Include root aliases in the command name conflict test --- tests/bot/cogs/test_cogs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index fdda59a8f..30a04422a 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -53,6 +53,7 @@ class CommandNameTests(unittest.TestCase): """Return a list of all qualified names, including aliases, for the `command`.""" names = [f"{command.full_parent_name} {alias}".strip() for alias in command.aliases] names.append(command.qualified_name) + names += getattr(command, "root_aliases", []) return names -- cgit v1.2.3 From fa92df15a4644d01256edeb440242ae92dc8adf0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Aug 2020 20:52:21 -0700 Subject: Help: include root aliases in output --- bot/cogs/help.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 3d1d6fd10..25ce4ae0f 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -189,7 +189,9 @@ class CustomHelpCommand(HelpCommand): command_details = f"**```{PREFIX}{name} {command.signature}```**\n" # show command aliases - aliases = ", ".join(f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in command.aliases) + aliases = [f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in command.aliases] + aliases += [f"`{alias}`" for alias in getattr(command, "root_aliases", ())] + aliases = ", ".join(sorted(aliases)) if aliases: command_details += f"**Can also use:** {aliases}\n\n" -- cgit v1.2.3 From 075110f6300da0525dec0aadb6530409549a02f5 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sun, 23 Aug 2020 14:36:06 +0100 Subject: Address review comments from @kwzrd --- bot/cogs/information.py | 33 ++++++++++++++------------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 776a0d474..c9412948a 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -20,6 +20,12 @@ from bot.utils.time import time_since log = logging.getLogger(__name__) +STATUS_EMOTES = { + Status.offline: constants.Emojis.status_offline, + Status.dnd: constants.Emojis.status_dnd, + Status.idle: constants.Emojis.status_idle +} + class Information(Cog): """A cog with commands for generating embeds with server info, such as server stats and user info.""" @@ -184,18 +190,6 @@ class Information(Cog): await ctx.send(embed=embed) - @staticmethod - def status_to_emoji(status: Status) -> str: - """Convert a Discord status into the relevant emoji.""" - if status is Status.offline: - return constants.Emojis.status_offline - elif status is Status.dnd: - return constants.Emojis.status_dnd - elif status is Status.idle: - return constants.Emojis.status_idle - else: - return constants.Emojis.status_online - @command(name="user", aliases=["user_info", "member", "member_info"]) async def user_info(self, ctx: Context, user: Member = None) -> None: """Returns info about a user.""" @@ -231,6 +225,7 @@ class Information(Cog): emoji = "" if activity.emoji: + # Confirm that the emoji is not a custom emoji since we cannot use them. if not activity.emoji.id: emoji += activity.emoji.name + " " @@ -240,18 +235,18 @@ class Information(Cog): if user.nick: name = f"{user.nick} ({name})" - badges = "" + badges = [] for badge, is_set in user.public_flags: - if is_set and (emoji := getattr(constants.Emojis, f"badge_{badge}")): - badges += emoji + " " + if is_set and (emoji := getattr(constants.Emojis, f"badge_{badge}", None)): + badges.append(emoji) joined = time_since(user.joined_at, max_units=3) roles = ", ".join(role.mention for role in user.roles[1:]) - desktop_status = self.status_to_emoji(user.desktop_status) - web_status = self.status_to_emoji(user.web_status) - mobile_status = self.status_to_emoji(user.mobile_status) + desktop_status = STATUS_EMOTES.get(user.desktop_status, constants.Emojis.status_online) + web_status = STATUS_EMOTES.get(user.web_status, constants.Emojis.status_online) + mobile_status = STATUS_EMOTES.get(user.mobile_status, constants.Emojis.status_online) fields = [ ( @@ -290,7 +285,7 @@ class Information(Cog): # Let's build the embed now embed = Embed( title=name, - description=badges + description=" ".join(badges) ) for field_name, field_content in fields: -- cgit v1.2.3 From 2448c0530e24cb0aacb733f17dea7a6830fdd98b Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sun, 23 Aug 2020 15:11:41 +0100 Subject: Don't just exclude custom emoji, include the name of the emote --- bot/cogs/information.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index c9412948a..3ec6c33af 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -225,9 +225,11 @@ class Information(Cog): emoji = "" if activity.emoji: - # Confirm that the emoji is not a custom emoji since we cannot use them. + # If an emoji is unicode use the emoji, else write the emote like :abc: if not activity.emoji.id: emoji += activity.emoji.name + " " + else: + emoji += f"`:{activity.emoji.name}:` " custom_status = f'Status: {emoji}{state}\n' -- cgit v1.2.3 From 7c97e1954503185d41ddf3cdc9c9b5b64bbb0a46 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Aug 2020 10:17:24 -0700 Subject: Code block: clarify that the original message can be edited Fix #497 --- bot/cogs/codeblock/instructions.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 56b85a34f..84c7a5ea0 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -161,21 +161,24 @@ def get_instructions(content: str) -> Optional[str]: if not blocks: log.trace("No code blocks were found in message.") - return _get_no_ticks_message(content) + instructions = _get_no_ticks_message(content) else: log.trace("Searching results for a code block with invalid ticks.") block = next((block for block in blocks if block.tick != parsing.BACKTICK), None) if block: log.trace("A code block exists but has invalid ticks.") - return _get_bad_ticks_message(block) + instructions = _get_bad_ticks_message(block) else: log.trace("A code block exists but is missing a language.") block = blocks[0] # Check for a bad language first to avoid parsing content into an AST. - description = _get_bad_lang_message(block.content) - if not description: - description = _get_no_lang_message(block.content) + instructions = _get_bad_lang_message(block.content) + if not instructions: + instructions = _get_no_lang_message(block.content) - return description + if instructions: + instructions += "\nYou can **edit your original message** to correct your code block." + + return instructions -- cgit v1.2.3 From fa28bf6de4dd5c5412b68d4ad448e0b4cb15cfac Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Aug 2020 10:10:38 -0700 Subject: Type check root aliases Just like normal aliases, they should only be tuples or lists. This is likely done by discord.py to prevent accidentally passing a string when only a single alias is desired. --- bot/command.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/command.py b/bot/command.py index 92e61d97e..0fb900f7b 100644 --- a/bot/command.py +++ b/bot/command.py @@ -13,3 +13,6 @@ class Command(commands.Command): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.root_aliases = kwargs.get("root_aliases", []) + + if not isinstance(self.root_aliases, (list, tuple)): + raise TypeError("Root aliases of a command must be a list or a tuple of strings.") -- cgit v1.2.3 From 24f1e6521422be436cec90165506005e6aad4969 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 25 Aug 2020 10:51:17 -0700 Subject: Info: simplify channel redirection for the user command `in_whitelist_check` is a convenient utility that does the same thing as the previous code. --- bot/cogs/information.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 55ecb2836..00d2b731e 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -15,7 +15,7 @@ from bot import constants from bot.bot import Bot from bot.decorators import in_whitelist, with_role from bot.pagination import LinePaginator -from bot.utils.checks import InWhitelistCheckFailure, cooldown_with_role_bypass, with_role_check +from bot.utils.checks import cooldown_with_role_bypass, in_whitelist_check, with_role_check from bot.utils.time import time_since log = logging.getLogger(__name__) @@ -201,14 +201,10 @@ class Information(Cog): await ctx.send("You may not use this command on users other than yourself.") return - # Non-staff may only do this in #bot-commands - if not with_role_check(ctx, *constants.STAFF_ROLES): - if not ctx.channel.id == constants.Channels.bot_commands: - raise InWhitelistCheckFailure(constants.Channels.bot_commands) - - embed = await self.create_user_embed(ctx, user) - - await ctx.send(embed=embed) + # Will redirect to #bot-commands if it fails. + if in_whitelist_check(ctx, roles=constants.STAFF_ROLES): + embed = await self.create_user_embed(ctx, user) + await ctx.send(embed=embed) async def create_user_embed(self, ctx: Context, user: Member) -> Embed: """Creates an embed containing information on the `user`.""" -- cgit v1.2.3 From c14eea25265754d8f4e82ef564db1d01ae4f0ca7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 25 Aug 2020 11:00:52 -0700 Subject: Info: show verbose infractions if user cmd invoked in modmail Fix #1125 --- bot/cogs/information.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 00d2b731e..81d326d3f 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -273,8 +273,14 @@ class Information(Cog): ) ] + # Use getattr to future-proof for commands invoked via DMs. + show_verbose = ( + ctx.channel.id in constants.MODERATION_CHANNELS + or getattr(ctx.channel, "category_id", None) == constants.Categories.modmail + ) + # Show more verbose output in moderation channels for infractions and nominations - if ctx.channel.id in constants.MODERATION_CHANNELS: + if show_verbose: fields.append(await self.expanded_user_infraction_counts(user)) fields.append(await self.user_nomination_counts(user)) else: -- cgit v1.2.3 From e1d13efb6d8871a53830860827ac2016a6cc279d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 25 Aug 2020 11:10:24 -0700 Subject: Use category_id attribute in is_in_category Simplify the code by removing the need to check if the category is None. --- bot/utils/channel.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/utils/channel.py b/bot/utils/channel.py index 47f70ce31..851f9e1fe 100644 --- a/bot/utils/channel.py +++ b/bot/utils/channel.py @@ -17,8 +17,7 @@ def is_help_channel(channel: discord.TextChannel) -> bool: def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: """Return True if `channel` is within a category with `category_id`.""" - actual_category = getattr(channel, "category", None) - return actual_category is not None and actual_category.id == category_id + return getattr(channel, "category_id", None) == category_id async def try_get_channel(channel_id: int, client: discord.Client) -> discord.abc.GuildChannel: -- cgit v1.2.3 From d53b48b3b370bd87c0c6103cc54fef7a79c24625 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 25 Aug 2020 11:13:56 -0700 Subject: Stats: use the is_in_category util function --- bot/cogs/stats.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/stats.py b/bot/cogs/stats.py index d42f55466..7b7470d8d 100644 --- a/bot/cogs/stats.py +++ b/bot/cogs/stats.py @@ -7,6 +7,7 @@ from discord.ext.tasks import loop from bot.bot import Bot from bot.constants import Categories, Channels, Guild, Stats as StatConf +from bot.utils.channel import is_in_category CHANNEL_NAME_OVERRIDES = { @@ -36,8 +37,7 @@ class Stats(Cog): if message.guild.id != Guild.id: return - cat = getattr(message.channel, "category", None) - if cat is not None and cat.id == Categories.modmail: + if is_in_category(message.channel, Categories.modmail): if message.channel.id != Channels.incidents: # Do not report modmail channels to stats, there are too many # of them for interesting statistics to be drawn out of this. -- cgit v1.2.3 From a955b61aa7e4692a99034357c7b56d488327a2a4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 25 Aug 2020 11:18:34 -0700 Subject: Code block: make _get_leading_spaces more readable A for loop is less confusing according to reviews. --- bot/cogs/codeblock/parsing.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index ea007b6f1..01c220c61 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -179,14 +179,12 @@ def parse_bad_language(content: str) -> Optional[BadLanguage]: def _get_leading_spaces(content: str) -> int: """Return the number of spaces at the start of the first line in `content`.""" - current = content[0] leading_spaces = 0 - - while current == " ": - leading_spaces += 1 - current = content[leading_spaces] - - return leading_spaces + for char in content: + if char == " ": + leading_spaces += 1 + else: + return leading_spaces def _fix_indentation(content: str) -> str: -- cgit v1.2.3 From 474d78704d852eec106df8d6f64783d0216f4b7f Mon Sep 17 00:00:00 2001 From: Boris Muratov Date: Wed, 26 Aug 2020 02:42:20 +0300 Subject: Bold link to asking guide in embeds --- bot/cogs/help_channels.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 57094751e..541c6f336 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -36,7 +36,7 @@ the **Help: Dormant** category. Try to write the best question you can by providing a detailed description and telling us what \ you've tried already. For more information on asking a good question, \ -check out our guide on [asking good questions]({ASKING_GUIDE_URL}). +check out our guide on [**asking good questions**]({ASKING_GUIDE_URL}). """ DORMANT_MSG = f""" @@ -47,7 +47,7 @@ channel until it becomes available again. If your question wasn't answered yet, you can claim a new help channel from the \ **Help: Available** category by simply asking your question again. Consider rephrasing the \ question to maximize your chance of getting a good answer. If you're not sure how, have a look \ -through our guide for [asking a good question]({ASKING_GUIDE_URL}). +through our guide for [**asking a good question**]({ASKING_GUIDE_URL}). """ CoroutineFunc = t.Callable[..., t.Coroutine] -- cgit v1.2.3 From a565bb529cbfcb241a7cf63c114a17f695451721 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 26 Aug 2020 18:31:42 +0200 Subject: Verification: add guild invite to config --- bot/constants.py | 1 + config-default.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index 0902858ac..daef6c095 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -458,6 +458,7 @@ class Guild(metaclass=YAMLGetter): section = "guild" id: int + invite: str # Discord invite, gets embedded in chat moderation_channels: List[int] moderation_roles: List[int] modlog_blacklist: List[int] diff --git a/config-default.yml b/config-default.yml index 58bdbe20f..a98fd14ef 100644 --- a/config-default.yml +++ b/config-default.yml @@ -123,6 +123,7 @@ style: guild: id: 267624335836053506 + invite: "https://discord.gg/python" categories: help_available: 691405807388196926 -- cgit v1.2.3 From 53189e815a5c260bb2636914ecd79f4f4c1182a0 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 26 Aug 2020 18:33:16 +0200 Subject: Verification: send guild invite with kick message Makes it easy for users to re-join. Co-authored-by: Joe Banks --- bot/cogs/verification.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 08d54d575..56b469a3e 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -62,7 +62,8 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! # Sent via DMs to users kicked for failing to verify KICKED_MESSAGE = f""" Hi! You have been automatically kicked from Python Discord as you have failed to accept our rules \ -within `{KICKED_AFTER}` days. If this was an accident, please feel free to join again. +within `{KICKED_AFTER}` days. If this was an accident, please feel free to join us again! +{constants.Guild.invite} """ # Sent periodically in the verification channel -- cgit v1.2.3 From 3ea04e3ddeaeef1d7931df8f5b84293d5eac6a04 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 26 Aug 2020 18:50:57 +0200 Subject: Verification: separate guild invite by empty line Co-authored-by: Joe Banks --- bot/cogs/verification.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 56b469a3e..a35681988 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -63,6 +63,7 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! KICKED_MESSAGE = f""" Hi! You have been automatically kicked from Python Discord as you have failed to accept our rules \ within `{KICKED_AFTER}` days. If this was an accident, please feel free to join us again! + {constants.Guild.invite} """ -- cgit v1.2.3 From eff2f75321fce0e8a8d11a1c85c2dad48552ded8 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 26 Aug 2020 19:34:14 +0200 Subject: Verification: remove explicit everyones from allowed mentions If the kwarg isn't passed, it uses the value that was given to the bot on init (False), despite the kwarg defaulting to True. Thanks to Mark and Senjan for helping me understand this. Co-authored-by: MarkKoz Co-authored-by: Senjan21 <53477086+senjan21@users.noreply.github.com> --- bot/cogs/verification.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index a35681988..a0a82be0c 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -79,12 +79,8 @@ You will be kicked if you don't verify within `{KICKED_AFTER}` days. REMINDER_FREQUENCY = 28 # Hours to wait between sending `REMINDER_MESSAGE` -MENTION_CORE_DEVS = discord.AllowedMentions( - everyone=False, roles=[discord.Object(constants.Roles.core_developers)] -) -MENTION_UNVERIFIED = discord.AllowedMentions( - everyone=False, roles=[discord.Object(constants.Roles.unverified)] -) +MENTION_CORE_DEVS = discord.AllowedMentions(roles=[discord.Object(constants.Roles.core_developers)]) +MENTION_UNVERIFIED = discord.AllowedMentions(roles=[discord.Object(constants.Roles.unverified)]) # An async function taking a Member param Request = t.Callable[[discord.Member], t.Awaitable] -- cgit v1.2.3 From 44aae4528ecec5eef8e2b56f7ac851219b35f080 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 26 Aug 2020 19:42:31 +0200 Subject: Verification: retain ping in edited confirmation msg Prevent a ghost ping from occurring upon reaction. Co-authored-by: Senjan21 <53477086+senjan21@users.noreply.github.com> --- bot/cogs/verification.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index a0a82be0c..d21395a1c 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -192,11 +192,12 @@ class Verification(Cog): # Since `n_members` is a suspiciously large number, we will ask for confirmation log.debug("Amount of users is too large, requesting staff confirmation") - core_devs = pydis.get_channel(constants.Channels.dev_core) - confirmation_msg = await core_devs.send( - f"<@&{constants.Roles.core_developers}> Verification determined that `{n_members}` members should " - f"be kicked as they haven't verified in `{KICKED_AFTER}` days. This is `{percentage:.2%}` of the " - f"guild's population. Proceed?", + core_dev_channel = pydis.get_channel(constants.Channels.dev_core) + core_dev_ping = f"<@&{constants.Roles.core_developers}>" + + confirmation_msg = await core_dev_channel.send( + f"{core_dev_ping} Verification determined that `{n_members}` members should be kicked as they haven't " + f"verified in `{KICKED_AFTER}` days. This is `{percentage:.2%}` of the guild's population. Proceed?", allowed_mentions=MENTION_CORE_DEVS, ) @@ -229,9 +230,9 @@ class Verification(Cog): # Edit the prompt message to reflect the final choice if result is True: - result_msg = f":ok_hand: Request to kick `{n_members}` members was authorized!" + result_msg = f":ok_hand: {core_dev_ping} Request to kick `{n_members}` members was authorized!" else: - result_msg = f":warning: Request to kick `{n_members}` members was denied!" + result_msg = f":warning: {core_dev_ping} Request to kick `{n_members}` members was denied!" with suppress(discord.HTTPException): await confirmation_msg.edit(content=result_msg) -- cgit v1.2.3 From 3672da9d6ad16452205e00a86162314f457fbbd0 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 26 Aug 2020 22:26:15 +0200 Subject: Verification: add helper for alerting admins --- bot/cogs/verification.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index d21395a1c..d5216c7c0 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -79,6 +79,7 @@ You will be kicked if you don't verify within `{KICKED_AFTER}` days. REMINDER_FREQUENCY = 28 # Hours to wait between sending `REMINDER_MESSAGE` +MENTION_ADMINS = discord.AllowedMentions(roles=[discord.Object(constants.Roles.admins)]) MENTION_CORE_DEVS = discord.AllowedMentions(roles=[discord.Object(constants.Roles.core_developers)]) MENTION_UNVERIFIED = discord.AllowedMentions(roles=[discord.Object(constants.Roles.unverified)]) @@ -239,6 +240,23 @@ class Verification(Cog): return result + async def _alert_admins(self, exception: discord.HTTPException) -> None: + """ + Ping @Admins with information about `exception`. + + This is used when a critical `exception` caused a verification task to abort. + """ + await self.bot.wait_until_guild_available() + log.info(f"Sending admin alert regarding exception: {exception}") + + admins_channel = self.bot.get_guild(constants.Guild.id).get_channel(constants.Channels.admins) + ping = f"<@&{constants.Roles.admins}>" + + await admins_channel.send( + f"{ping} Aborted updating unverified users due to the following exception:\n```{exception}```", + allowed_mentions=MENTION_ADMINS, + ) + async def _send_requests(self, members: t.Collection[discord.Member], request: Request, limit: Limit) -> int: """ Pass `members` one by one to `request` handling Discord exceptions. -- cgit v1.2.3 From 1d6d845d2f052c74d6d92a1a98b537430296cc85 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 26 Aug 2020 22:27:47 +0200 Subject: Verification: stop kicking members on suspicious 403 A Discord error code 50_0007 signifies that the DM dispatch failed because the target user does not accept DMs from the bot. Such errors are ignored as before. Any other 403s will however cause the bot to stop making requests. This is in case the bot gets caught by an anti-spam filter and should immediately stop. --- bot/cogs/verification.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index d5216c7c0..196808b0d 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -87,6 +87,14 @@ MENTION_UNVERIFIED = discord.AllowedMentions(roles=[discord.Object(constants.Rol Request = t.Callable[[discord.Member], t.Awaitable] +class StopExecution(Exception): + """Signals that a task should halt immediately & alert admins.""" + + def __init__(self, reason: discord.HTTPException) -> None: + super().__init__() + self.reason = reason + + class Limit(t.NamedTuple): """Composition over config for throttling requests.""" @@ -277,6 +285,9 @@ class Verification(Cog): continue try: await request(member) + except StopExecution as stop_execution: + await self._alert_admins(stop_execution.reason) + break except discord.HTTPException as http_exc: bad_statuses.add(http_exc.status) else: @@ -304,8 +315,12 @@ class Verification(Cog): async def kick_request(member: discord.Member) -> None: """Send `KICKED_MESSAGE` to `member` and kick them from the guild.""" - with suppress(discord.Forbidden): + try: await member.send(KICKED_MESSAGE) + except discord.Forbidden as exc_403: + log.trace(f"DM dispatch failed on 403 error with code: {exc_403.code}") + if exc_403.code != 50_007: # 403 raised for any other reason than disabled DMs + raise StopExecution(reason=exc_403) await member.kick(reason=f"User has not verified in {KICKED_AFTER} days") n_kicked = await self._send_requests(members, kick_request, Limit(batch_size=2, sleep_secs=1)) -- cgit v1.2.3 From 6efd6e5f22afc79d493b08d764e3fa666f9c8033 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 26 Aug 2020 18:12:00 -0700 Subject: Fix infraction counts being shown in wrong channels They should only be shown in mod channels, not all staff channels. Fix #1127 --- bot/cogs/moderation/scheduler.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 051f6c52c..13180c2b6 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -12,7 +12,7 @@ from discord.ext.commands import Context from bot import constants from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Colours, STAFF_CHANNELS +from bot.constants import Colours, MODERATION_CHANNELS from bot.utils import time from bot.utils.scheduling import Scheduler from . import utils @@ -137,9 +137,9 @@ class InfractionScheduler: ) if reason: end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})" - elif ctx.channel.id not in STAFF_CHANNELS: + elif ctx.channel.id not in MODERATION_CHANNELS: log.trace( - f"Infraction #{id_} context is not in a staff channel; omitting infraction count." + f"Infraction #{id_} context is not in a mod channel; omitting infraction count." ) else: log.trace(f"Fetching total infraction count for {user}.") -- cgit v1.2.3 From ac97a3220b6bc04aa3b46567a96ef7606d156687 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 26 Aug 2020 18:37:42 -0700 Subject: Fix user command tests Mocking the bot commands channel constant no longer worked after switching to `in_whitelist_check`. --- tests/bot/cogs/test_information.py | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 77b0ddf17..3438635f1 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -532,10 +532,13 @@ class UserCommandTests(unittest.TestCase): self.moderator = helpers.MockMember(id=2, name="riffautae", roles=[self.moderator_role]) self.target = helpers.MockMember(id=3, name="__fluzz__") + # There's no way to mock the channel constant without deferring imports. The constant is + # used as a default value for a parameter, which gets defined upon import. + self.bot_command_channel = helpers.MockTextChannel(id=constants.Channels.bot_commands) + def test_regular_member_cannot_target_another_member(self, constants): """A regular user should not be able to use `!user` targeting another user.""" constants.MODERATION_ROLES = [self.moderator_role.id] - ctx = helpers.MockContext(author=self.author) asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target)) @@ -546,8 +549,6 @@ class UserCommandTests(unittest.TestCase): """A regular user should not be able to use this command outside of bot-commands.""" constants.MODERATION_ROLES = [self.moderator_role.id] constants.STAFF_ROLES = [self.moderator_role.id] - constants.Channels.bot_commands = 50 - ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=100)) msg = "Sorry, but you may only use this command within <#50>." @@ -558,9 +559,7 @@ class UserCommandTests(unittest.TestCase): def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants): """A regular user should be allowed to use `!user` targeting themselves in bot-commands.""" constants.STAFF_ROLES = [self.moderator_role.id] - constants.Channels.bot_commands = 50 - - ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) + ctx = helpers.MockContext(author=self.author, channel=self.bot_command_channel) asyncio.run(self.cog.user_info.callback(self.cog, ctx)) @@ -568,12 +567,10 @@ class UserCommandTests(unittest.TestCase): ctx.send.assert_called_once() @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock) - def test_regular_user_can_explicitly_target_themselves(self, create_embed, constants): + def test_regular_user_can_explicitly_target_themselves(self, create_embed, _): """A user should target itself with `!user` when a `user` argument was not provided.""" constants.STAFF_ROLES = [self.moderator_role.id] - constants.Channels.bot_commands = 50 - - ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) + ctx = helpers.MockContext(author=self.author, channel=self.bot_command_channel) asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.author)) @@ -584,8 +581,6 @@ class UserCommandTests(unittest.TestCase): def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants): """Staff members should be able to bypass the bot-commands channel restriction.""" constants.STAFF_ROLES = [self.moderator_role.id] - constants.Channels.bot_commands = 50 - ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=200)) asyncio.run(self.cog.user_info.callback(self.cog, ctx)) @@ -598,7 +593,6 @@ class UserCommandTests(unittest.TestCase): """A moderator should be able to use `!user` targeting another user.""" constants.MODERATION_ROLES = [self.moderator_role.id] constants.STAFF_ROLES = [self.moderator_role.id] - ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=50)) asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target)) -- cgit v1.2.3 From 0ba23a7c47813280ab6157396e43d61e8fc7b4d2 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 27 Aug 2020 10:07:23 +0200 Subject: Verification: document StopExecution handling --- bot/cogs/verification.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 196808b0d..984f3cc95 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -272,6 +272,9 @@ class Verification(Cog): This coroutine serves as a generic `request` executor for kicking members and adding roles, as it allows us to define the error handling logic in one place only. + Any `request` has the ability to completely abort the execution by raising `StopExecution`. + In such a case, the @Admins will be alerted of the reason attribute. + To avoid rate-limits, pass a `limit` configuring the batch size and the amount of seconds to sleep between batches. -- cgit v1.2.3 From 69314d4b812361f2b2a02018093f9a504ac4674f Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 27 Aug 2020 14:29:21 +0200 Subject: Verification: improve allowed mentions handling I really didn't like the constants, but the construction of allowed mentions instances is syntactically noisy, so I prefer to keep it out of the important logic. Abstracting it behind a function seems to be the best approach yet. --- bot/cogs/verification.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 984f3cc95..0ae3c5b4c 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -79,10 +79,6 @@ You will be kicked if you don't verify within `{KICKED_AFTER}` days. REMINDER_FREQUENCY = 28 # Hours to wait between sending `REMINDER_MESSAGE` -MENTION_ADMINS = discord.AllowedMentions(roles=[discord.Object(constants.Roles.admins)]) -MENTION_CORE_DEVS = discord.AllowedMentions(roles=[discord.Object(constants.Roles.core_developers)]) -MENTION_UNVERIFIED = discord.AllowedMentions(roles=[discord.Object(constants.Roles.unverified)]) - # An async function taking a Member param Request = t.Callable[[discord.Member], t.Awaitable] @@ -102,6 +98,11 @@ class Limit(t.NamedTuple): sleep_secs: int # Sleep this many seconds after each batch +def mention_role(role_id: int) -> discord.AllowedMentions: + """Construct an allowed mentions instance that allows pinging `role_id`.""" + return discord.AllowedMentions(roles=[discord.Object(role_id)]) + + def is_verified(member: discord.Member) -> bool: """ Check whether `member` is considered verified. @@ -207,7 +208,7 @@ class Verification(Cog): confirmation_msg = await core_dev_channel.send( f"{core_dev_ping} Verification determined that `{n_members}` members should be kicked as they haven't " f"verified in `{KICKED_AFTER}` days. This is `{percentage:.2%}` of the guild's population. Proceed?", - allowed_mentions=MENTION_CORE_DEVS, + allowed_mentions=mention_role(constants.Roles.core_developers), ) options = (constants.Emojis.incident_actioned, constants.Emojis.incident_unactioned) @@ -262,7 +263,7 @@ class Verification(Cog): await admins_channel.send( f"{ping} Aborted updating unverified users due to the following exception:\n```{exception}```", - allowed_mentions=MENTION_ADMINS, + allowed_mentions=mention_role(constants.Roles.admins), ) async def _send_requests(self, members: t.Collection[discord.Member], request: Request, limit: Limit) -> int: @@ -445,7 +446,9 @@ class Verification(Cog): await self.bot.http.delete_message(verification.id, last_reminder) log.trace("Sending verification reminder") - new_reminder = await verification.send(REMINDER_MESSAGE, allowed_mentions=MENTION_UNVERIFIED) + new_reminder = await verification.send( + REMINDER_MESSAGE, allowed_mentions=mention_role(constants.Roles.unverified), + ) await self.task_cache.set("last_reminder", new_reminder.id) -- cgit v1.2.3 From 30cbde7a7c48e59a19b5a7f1934d0e7674473d62 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 27 Aug 2020 12:26:27 -0700 Subject: AntiSpam: ignore custom emojis in code blocks In code blocks, custom emojis render as text rather than as images. Therefore, they probably aren't being spammed and should be ignored. Fix #1130 --- bot/rules/discord_emojis.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/rules/discord_emojis.py b/bot/rules/discord_emojis.py index 5bab514f2..6e47f0197 100644 --- a/bot/rules/discord_emojis.py +++ b/bot/rules/discord_emojis.py @@ -5,6 +5,7 @@ from discord import Member, Message DISCORD_EMOJI_RE = re.compile(r"<:\w+:\d+>") +CODE_BLOCK_RE = re.compile(r"```.*?```", flags=re.DOTALL) async def apply( @@ -17,8 +18,9 @@ async def apply( if msg.author == last_message.author ) + # Get rid of code blocks in the message before searching for emojis. total_emojis = sum( - len(DISCORD_EMOJI_RE.findall(msg.content)) + len(DISCORD_EMOJI_RE.findall(CODE_BLOCK_RE.sub("", msg.content))) for msg in relevant_messages ) -- cgit v1.2.3 From 7016124192f3228145195765b1c94535700e54aa Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Thu, 27 Aug 2020 23:14:38 +0100 Subject: Update Discord Partner badge --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index 8c0092e76..b4dc34e85 100644 --- a/config-default.yml +++ b/config-default.yml @@ -39,7 +39,7 @@ style: status_offline: "<:status_offline:470326266537705472>" badge_staff: "<:discord_staff:743882896498098226>" - badge_partner: "<:partner:743882897131569323>" + badge_partner: "<:partner:748666453242413136>" badge_hypesquad: "<:hypesquad_events:743882896892362873>" badge_bug_hunter: "<:bug_hunter_lvl1:743882896372269137>" badge_hypesquad_bravery: "<:hypesquad_bravery:743882896745693335>" -- cgit v1.2.3 From 2fcf07fd041fa58beca52cfa33540343b54e85fd Mon Sep 17 00:00:00 2001 From: AtieP Date: Sat, 29 Aug 2020 09:10:45 +0200 Subject: Remove unused variables and imports --- bot/cogs/help.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 76aaf655c..6caa211a6 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -10,7 +10,7 @@ from fuzzywuzzy import fuzz, process from fuzzywuzzy.utils import full_process from bot import constants -from bot.constants import Channels, Emojis, STAFF_ROLES +from bot.constants import Channels, STAFF_ROLES from bot.decorators import redirect_output from bot.pagination import LinePaginator from bot.utils.messages import wait_for_deletion @@ -18,7 +18,6 @@ from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) COMMANDS_PER_PAGE = 8 -DELETE_EMOJI = Emojis.trashcan PREFIX = constants.Bot.prefix Category = namedtuple("Category", ["name", "description", "cogs"]) -- cgit v1.2.3 From 8b10533851ca3fe3b44dd6662f634ae89550ad16 Mon Sep 17 00:00:00 2001 From: Xithrius Date: Sat, 29 Aug 2020 01:38:04 -0700 Subject: Completely gutted the wolfram command. Moved to seasonalbot/bot/exts/evergreen/wolfram.py --- bot/cogs/wolfram.py | 280 ---------------------------------------------------- bot/constants.py | 8 -- config-default.yml | 7 -- 3 files changed, 295 deletions(-) delete mode 100644 bot/cogs/wolfram.py diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py deleted file mode 100644 index e6cae3bb8..000000000 --- a/bot/cogs/wolfram.py +++ /dev/null @@ -1,280 +0,0 @@ -import logging -from io import BytesIO -from typing import Callable, List, Optional, Tuple -from urllib import parse - -import discord -from dateutil.relativedelta import relativedelta -from discord import Embed -from discord.ext import commands -from discord.ext.commands import BucketType, Cog, Context, check, group - -from bot.bot import Bot -from bot.constants import Colours, STAFF_ROLES, Wolfram -from bot.pagination import ImagePaginator -from bot.utils.time import humanize_delta - -log = logging.getLogger(__name__) - -APPID = Wolfram.key -DEFAULT_OUTPUT_FORMAT = "JSON" -QUERY = "http://api.wolframalpha.com/v2/{request}?{data}" -WOLF_IMAGE = "https://www.symbols.com/gi.php?type=1&id=2886&i=1" - -MAX_PODS = 20 - -# Allows for 10 wolfram calls pr user pr day -usercd = commands.CooldownMapping.from_cooldown(Wolfram.user_limit_day, 60*60*24, BucketType.user) - -# Allows for max api requests / days in month per day for the entire guild (Temporary) -guildcd = commands.CooldownMapping.from_cooldown(Wolfram.guild_limit_day, 60*60*24, BucketType.guild) - - -async def send_embed( - ctx: Context, - message_txt: str, - colour: int = Colours.soft_red, - footer: str = None, - img_url: str = None, - f: discord.File = None -) -> None: - """Generate & send a response embed with Wolfram as the author.""" - embed = Embed(colour=colour) - embed.description = message_txt - embed.set_author(name="Wolfram Alpha", - icon_url=WOLF_IMAGE, - url="https://www.wolframalpha.com/") - if footer: - embed.set_footer(text=footer) - - if img_url: - embed.set_image(url=img_url) - - await ctx.send(embed=embed, file=f) - - -def custom_cooldown(*ignore: List[int]) -> Callable: - """ - Implement per-user and per-guild cooldowns for requests to the Wolfram API. - - A list of roles may be provided to ignore the per-user cooldown - """ - async def predicate(ctx: Context) -> bool: - if ctx.invoked_with == 'help': - # if the invoked command is help we don't want to increase the ratelimits since it's not actually - # invoking the command/making a request, so instead just check if the user/guild are on cooldown. - guild_cooldown = not guildcd.get_bucket(ctx.message).get_tokens() == 0 # if guild is on cooldown - if not any(r.id in ignore for r in ctx.author.roles): # check user bucket if user is not ignored - return guild_cooldown and not usercd.get_bucket(ctx.message).get_tokens() == 0 - return guild_cooldown - - user_bucket = usercd.get_bucket(ctx.message) - - if all(role.id not in ignore for role in ctx.author.roles): - user_rate = user_bucket.update_rate_limit() - - if user_rate: - # Can't use api; cause: member limit - delta = relativedelta(seconds=int(user_rate)) - cooldown = humanize_delta(delta) - message = ( - "You've used up your limit for Wolfram|Alpha requests.\n" - f"Cooldown: {cooldown}" - ) - await send_embed(ctx, message) - return False - - guild_bucket = guildcd.get_bucket(ctx.message) - guild_rate = guild_bucket.update_rate_limit() - - # Repr has a token attribute to read requests left - log.debug(guild_bucket) - - if guild_rate: - # Can't use api; cause: guild limit - message = ( - "The max limit of requests for the server has been reached for today.\n" - f"Cooldown: {int(guild_rate)}" - ) - await send_embed(ctx, message) - return False - - return True - return check(predicate) - - -async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[List[Tuple]]: - """Get the Wolfram API pod pages for the provided query.""" - async with ctx.channel.typing(): - url_str = parse.urlencode({ - "input": query, - "appid": APPID, - "output": DEFAULT_OUTPUT_FORMAT, - "format": "image,plaintext" - }) - request_url = QUERY.format(request="query", data=url_str) - - async with bot.http_session.get(request_url) as response: - json = await response.json(content_type='text/plain') - - result = json["queryresult"] - - if result["error"]: - # API key not set up correctly - if result["error"]["msg"] == "Invalid appid": - message = "Wolfram API key is invalid or missing." - log.warning( - "API key seems to be missing, or invalid when " - f"processing a wolfram request: {url_str}, Response: {json}" - ) - await send_embed(ctx, message) - return - - message = "Something went wrong internally with your request, please notify staff!" - log.warning(f"Something went wrong getting a response from wolfram: {url_str}, Response: {json}") - await send_embed(ctx, message) - return - - if not result["success"]: - message = f"I couldn't find anything for {query}." - await send_embed(ctx, message) - return - - if not result["numpods"]: - message = "Could not find any results." - await send_embed(ctx, message) - return - - pods = result["pods"] - pages = [] - for pod in pods[:MAX_PODS]: - subs = pod.get("subpods") - - for sub in subs: - title = sub.get("title") or sub.get("plaintext") or sub.get("id", "") - img = sub["img"]["src"] - pages.append((title, img)) - return pages - - -class Wolfram(Cog): - """Commands for interacting with the Wolfram|Alpha API.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @group(name="wolfram", aliases=("wolf", "wa"), invoke_without_command=True) - @custom_cooldown(*STAFF_ROLES) - async def wolfram_command(self, ctx: Context, *, query: str) -> None: - """Requests all answers on a single image, sends an image of all related pods.""" - url_str = parse.urlencode({ - "i": query, - "appid": APPID, - }) - query = QUERY.format(request="simple", data=url_str) - - # Give feedback that the bot is working. - async with ctx.channel.typing(): - async with self.bot.http_session.get(query) as response: - status = response.status - image_bytes = await response.read() - - f = discord.File(BytesIO(image_bytes), filename="image.png") - image_url = "attachment://image.png" - - if status == 501: - message = "Failed to get response" - footer = "" - color = Colours.soft_red - elif status == 400: - message = "No input found" - footer = "" - color = Colours.soft_red - elif status == 403: - message = "Wolfram API key is invalid or missing." - footer = "" - color = Colours.soft_red - else: - message = "" - footer = "View original for a bigger picture." - color = Colours.soft_orange - - # Sends a "blank" embed if no request is received, unsure how to fix - await send_embed(ctx, message, color, footer=footer, img_url=image_url, f=f) - - @wolfram_command.command(name="page", aliases=("pa", "p")) - @custom_cooldown(*STAFF_ROLES) - async def wolfram_page_command(self, ctx: Context, *, query: str) -> None: - """ - Requests a drawn image of given query. - - Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. - """ - pages = await get_pod_pages(ctx, self.bot, query) - - if not pages: - return - - embed = Embed() - embed.set_author(name="Wolfram Alpha", - icon_url=WOLF_IMAGE, - url="https://www.wolframalpha.com/") - embed.colour = Colours.soft_orange - - await ImagePaginator.paginate(pages, ctx, embed) - - @wolfram_command.command(name="cut", aliases=("c",)) - @custom_cooldown(*STAFF_ROLES) - async def wolfram_cut_command(self, ctx: Context, *, query: str) -> None: - """ - Requests a drawn image of given query. - - Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. - """ - pages = await get_pod_pages(ctx, self.bot, query) - - if not pages: - return - - if len(pages) >= 2: - page = pages[1] - else: - page = pages[0] - - await send_embed(ctx, page[0], colour=Colours.soft_orange, img_url=page[1]) - - @wolfram_command.command(name="short", aliases=("sh", "s")) - @custom_cooldown(*STAFF_ROLES) - async def wolfram_short_command(self, ctx: Context, *, query: str) -> None: - """Requests an answer to a simple question.""" - url_str = parse.urlencode({ - "i": query, - "appid": APPID, - }) - query = QUERY.format(request="result", data=url_str) - - # Give feedback that the bot is working. - async with ctx.channel.typing(): - async with self.bot.http_session.get(query) as response: - status = response.status - response_text = await response.text() - - if status == 501: - message = "Failed to get response" - color = Colours.soft_red - elif status == 400: - message = "No input found" - color = Colours.soft_red - elif response_text == "Error 1: Invalid appid": - message = "Wolfram API key is invalid or missing." - color = Colours.soft_red - else: - message = response_text - color = Colours.soft_orange - - await send_embed(ctx, message, color) - - -def setup(bot: Bot) -> None: - """Load the Wolfram cog.""" - bot.add_cog(Wolfram(bot)) diff --git a/bot/constants.py b/bot/constants.py index f3db80279..17fe34e95 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -514,14 +514,6 @@ class Reddit(metaclass=YAMLGetter): secret: Optional[str] -class Wolfram(metaclass=YAMLGetter): - section = "wolfram" - - user_limit_day: int - guild_limit_day: int - key: Optional[str] - - class AntiSpam(metaclass=YAMLGetter): section = 'anti_spam' diff --git a/config-default.yml b/config-default.yml index b4dc34e85..a0f601728 100644 --- a/config-default.yml +++ b/config-default.yml @@ -393,13 +393,6 @@ reddit: secret: !ENV "REDDIT_SECRET" -wolfram: - # Max requests per day. - user_limit_day: 10 - guild_limit_day: 67 - key: !ENV "WOLFRAM_API_KEY" - - big_brother: log_delay: 15 header_message_limit: 15 -- cgit v1.2.3 From c58ae662069d098dae45a36a5203b6d5f0048924 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 29 Aug 2020 18:29:24 +0200 Subject: Verification: add helper for stopping tasks --- bot/cogs/verification.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 0ae3c5b4c..a013a1b12 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -157,8 +157,7 @@ class Verification(Cog): This is necessary, as tasks are not automatically cancelled on cog unload. """ - self.update_unverified_members.cancel() - self.ping_unverified.cancel() + self._stop_tasks(gracefully=False) @property def mod_log(self) -> ModLog: @@ -179,6 +178,21 @@ class Verification(Cog): self.update_unverified_members.start() self.ping_unverified.start() + def _stop_tasks(self, *, gracefully: bool) -> None: + """ + Stop the update users & ping @Unverified tasks. + + If `gracefully` is True, the tasks will be able to finish their current iteration. + Otherwise, they are cancelled immediately. + """ + log.info(f"Stopping internal tasks ({gracefully=})") + if gracefully: + self.update_unverified_members.stop() + self.ping_unverified.stop() + else: + self.update_unverified_members.cancel() + self.ping_unverified.cancel() + # region: automatically update unverified users async def _verify_kick(self, n_members: int) -> bool: @@ -607,9 +621,7 @@ class Verification(Cog): """Stop verification tasks.""" log.info("Stopping verification tasks") - self.update_unverified_members.cancel() - self.ping_unverified.cancel() - + self._stop_tasks(gracefully=False) await self.task_cache.set("tasks_running", 0) colour = discord.Colour.blurple() -- cgit v1.2.3 From cf7388ff8490be95f1d677f424e8bec86de0e46a Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 29 Aug 2020 18:32:12 +0200 Subject: Verification: stop tasks on suspicious 403 --- bot/cogs/verification.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index a013a1b12..107ae1178 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -276,7 +276,9 @@ class Verification(Cog): ping = f"<@&{constants.Roles.admins}>" await admins_channel.send( - f"{ping} Aborted updating unverified users due to the following exception:\n```{exception}```", + f"{ping} Aborted updating unverified users due to the following exception:\n" + f"```{exception}```\n" + f"Internal tasks will be stopped.", allowed_mentions=mention_role(constants.Roles.admins), ) @@ -305,6 +307,7 @@ class Verification(Cog): await request(member) except StopExecution as stop_execution: await self._alert_admins(stop_execution.reason) + self._stop_tasks(gracefully=True) # Gracefully finish current iteration, then stop break except discord.HTTPException as http_exc: bad_statuses.add(http_exc.status) -- cgit v1.2.3 From ad305f2eb3bb33f5ce7bb3abd7def9f436928c8e Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 29 Aug 2020 18:37:32 +0200 Subject: Verification: denote `_maybe_start_tasks` as private Consistency with the new `_stop_tasks` method. --- bot/cogs/verification.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 107ae1178..5c8962577 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -149,7 +149,7 @@ class Verification(Cog): def __init__(self, bot: Bot) -> None: """Start internal tasks.""" self.bot = bot - self.bot.loop.create_task(self.maybe_start_tasks()) + self.bot.loop.create_task(self._maybe_start_tasks()) def cog_unload(self) -> None: """ @@ -164,7 +164,7 @@ class Verification(Cog): """Get currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") - async def maybe_start_tasks(self) -> None: + async def _maybe_start_tasks(self) -> None: """ Poll Redis to check whether internal tasks should start. -- cgit v1.2.3 From 6c98df046c535282c6d4b194b0766afcddbdf669 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 29 Aug 2020 19:54:27 +0200 Subject: Verification: set 'tasks_running' to 0 on suspicious 403s Prevent the tasks from starting again if the bot restarts. --- bot/cogs/verification.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 5c8962577..08f7c282e 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -307,6 +307,7 @@ class Verification(Cog): await request(member) except StopExecution as stop_execution: await self._alert_admins(stop_execution.reason) + await self.task_cache.set("tasks_running", 0) self._stop_tasks(gracefully=True) # Gracefully finish current iteration, then stop break except discord.HTTPException as http_exc: -- cgit v1.2.3 From 40ad0def564109884c607c78f95c67518d7a70a5 Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Fri, 28 Aug 2020 11:53:08 -0500 Subject: Everyone Ping: Add rules to default config file --- config-default.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config-default.yml b/config-default.yml index 8c0092e76..3a5918983 100644 --- a/config-default.yml +++ b/config-default.yml @@ -385,6 +385,9 @@ anti_spam: interval: 10 max: 3 + everyone_ping: + enabled: true + reddit: subreddits: -- cgit v1.2.3 From df4ef2e520cd672f0bb46b9d5d09a04647ca2ccf Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Fri, 28 Aug 2020 19:06:19 -0500 Subject: Everyone Ping: Added rule Added the filter rule to the bot/rules folder. --- bot/rules/everyone_ping.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 bot/rules/everyone_ping.py diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py new file mode 100644 index 000000000..29a734478 --- /dev/null +++ b/bot/rules/everyone_ping.py @@ -0,0 +1,31 @@ +from typing import Dict, Iterable, List, Optional, Tuple + +from discord import Member, Message + + +async def apply( + last_message: Message, + recent_messages: List[Message], + config: Dict[str, int], +) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: + """Detects if a user has sent an '@everyone' ping.""" + relevant_messages = tuple( + msg for msg in recent_messages if msg.author == last_message.author + ) + + ev_msgs_ct = 0 + if config["enabled"]: + for msg in relevant_messages: + ev_role = msg.guild.default_role + msg_roles = msg.role_mentions + + if ev_role in msg_roles: + ev_msgs_ct += 1 + + if ev_msgs_ct > 0: + return ( + f"pinged the everyone role {ev_msgs_ct} times", + (last_message.author), + relevant_messages, + ) + return None -- cgit v1.2.3 From 99aa7d55a72fdbf4265820e9d6f70d95132faa8f Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Fri, 28 Aug 2020 19:14:29 -0500 Subject: Everyone Ping: Added rule to recognized rules Added mapping to anti-spam cog, then also edited __init__ in the rules folder to expose the apply function. --- bot/cogs/antispam.py | 3 ++- bot/rules/__init__.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index bc31cbd95..d003f962b 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -34,7 +34,8 @@ RULE_FUNCTION_MAPPING = { 'links': rules.apply_links, 'mentions': rules.apply_mentions, 'newlines': rules.apply_newlines, - 'role_mentions': rules.apply_role_mentions + 'role_mentions': rules.apply_role_mentions, + 'everyone_ping': rules.apply_everyone_ping, } diff --git a/bot/rules/__init__.py b/bot/rules/__init__.py index a01ceae73..8a69cadee 100644 --- a/bot/rules/__init__.py +++ b/bot/rules/__init__.py @@ -10,3 +10,4 @@ from .links import apply as apply_links from .mentions import apply as apply_mentions from .newlines import apply as apply_newlines from .role_mentions import apply as apply_role_mentions +from .everyone_ping import apply as apply_everyone_ping -- cgit v1.2.3 From f873e685e34f1af62f2bc49bc3e37265c327b3ea Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Fri, 28 Aug 2020 19:30:34 -0500 Subject: Everyone Ping: Added required values to config The `max` and `interval` values were required, so they were added to the config file and the rule was modified to accept these new values. --- bot/rules/everyone_ping.py | 2 +- config-default.yml | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 29a734478..342727093 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -14,7 +14,7 @@ async def apply( ) ev_msgs_ct = 0 - if config["enabled"]: + if config["max"]: for msg in relevant_messages: ev_role = msg.guild.default_role msg_roles = msg.role_mentions diff --git a/config-default.yml b/config-default.yml index 3a5918983..18d7f4b0e 100644 --- a/config-default.yml +++ b/config-default.yml @@ -386,7 +386,8 @@ anti_spam: max: 3 everyone_ping: - enabled: true + interval: 1 + max: 1 reddit: -- cgit v1.2.3 From c55b7e3749166d06f66193692a7ded5d1317a154 Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Fri, 28 Aug 2020 20:21:01 -0500 Subject: Everyone Ping: Fixed rule, edited config Changed the method of checking for an everyone ping. Also changed the config to act as `min pings` instead of `ping enabled/disabled`. --- bot/rules/everyone_ping.py | 16 ++++++---------- config-default.yml | 4 ++-- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 342727093..bfc400831 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -14,18 +14,14 @@ async def apply( ) ev_msgs_ct = 0 - if config["max"]: - for msg in relevant_messages: - ev_role = msg.guild.default_role - msg_roles = msg.role_mentions + for msg in relevant_messages: + if '@everyone' in msg.content: + ev_msgs_ct += 1 - if ev_role in msg_roles: - ev_msgs_ct += 1 - - if ev_msgs_ct > 0: + if ev_msgs_ct >= config['max']: return ( - f"pinged the everyone role {ev_msgs_ct} times", - (last_message.author), + f"pinged the everyone role {ev_msgs_ct} times in {config['interval']}s", + (last_message.author,), relevant_messages, ) return None diff --git a/config-default.yml b/config-default.yml index 18d7f4b0e..8546b5310 100644 --- a/config-default.yml +++ b/config-default.yml @@ -386,8 +386,8 @@ anti_spam: max: 3 everyone_ping: - interval: 1 - max: 1 + interval: 10 + max: 0 reddit: -- cgit v1.2.3 From 24002b6b585962bf9218ad643727b30d4ed018dd Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Sat, 29 Aug 2020 16:06:19 -0500 Subject: Everyone ping: Send embed on ping, fixed check When a user pings the everyone role, they now get an embed explaining why what they did was wrong. The ping detection was also fixed to not thing that every message was a ping (changed form `>=` to `>`). --- bot/rules/everyone_ping.py | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index bfc400831..65ee1062c 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -1,6 +1,14 @@ +import logging +import textwrap from typing import Dict, Iterable, List, Optional, Tuple -from discord import Member, Message +from discord import Embed, Member, Message + +from bot.cogs.moderation.utils import send_private_embed +from bot.constants import Colours + +# For embed sender +log = logging.getLogger(__name__) async def apply( @@ -15,10 +23,28 @@ async def apply( ev_msgs_ct = 0 for msg in relevant_messages: - if '@everyone' in msg.content: + if "@everyone" in msg.content: ev_msgs_ct += 1 - if ev_msgs_ct >= config['max']: + if ev_msgs_ct > config["max"]: + # Send the user an embed giving them more info: + member_count = "{:,}".format(last_message.guild.member_count).split( + "," + )[0] + embed_text = textwrap.dedent( + f""" + Hello {last_message.author.display_name}, please don't try to ping {member_count}k people. + **It will not have good results.** + If you want to know what it would be like, imagine pinging Greenland. Please don't ping Greenland. + """ + ) + print(embed_text) + embed = Embed( + title="Everyone Ping Mute Info", + colour=Colours.soft_red, + description=embed_text, + ) + await send_private_embed(last_message.author, embed) return ( f"pinged the everyone role {ev_msgs_ct} times in {config['interval']}s", (last_message.author,), -- cgit v1.2.3 From 218e50ce41dea40ec04614db1888cd44db7843b5 Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Sat, 29 Aug 2020 17:09:24 -0500 Subject: Everyone Ping: Removed debug `print`, spelling Removed a debug `print` statement, fixed a spelling mistake. Also added a comment for the DM string. --- bot/rules/everyone_ping.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 65ee1062c..b99e75059 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -31,14 +31,14 @@ async def apply( member_count = "{:,}".format(last_message.guild.member_count).split( "," )[0] + # Change the `K` to an `M` once the server reaches over 1 million people. embed_text = textwrap.dedent( f""" - Hello {last_message.author.display_name}, please don't try to ping {member_count}k people. + Hello {last_message.author.display_name}, please don't try to ping {member_count}K people. **It will not have good results.** If you want to know what it would be like, imagine pinging Greenland. Please don't ping Greenland. """ ) - print(embed_text) embed = Embed( title="Everyone Ping Mute Info", colour=Colours.soft_red, -- cgit v1.2.3 From e42db79c2fd7be4b0c82a5ba4e3f1ca4349745a2 Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Sat, 29 Aug 2020 18:27:07 -0500 Subject: Everyone Ping: Changed embed text and location The you can view the embed text in the `everyone_ping.py` file. The embed also now sends in the server instead of a DM. --- bot/rules/everyone_ping.py | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index b99e75059..47931caae 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -4,7 +4,6 @@ from typing import Dict, Iterable, List, Optional, Tuple from discord import Embed, Member, Message -from bot.cogs.moderation.utils import send_private_embed from bot.constants import Colours # For embed sender @@ -17,9 +16,7 @@ async def apply( config: Dict[str, int], ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: """Detects if a user has sent an '@everyone' ping.""" - relevant_messages = tuple( - msg for msg in recent_messages if msg.author == last_message.author - ) + relevant_messages = tuple(msg for msg in recent_messages if msg.author == last_message.author) ev_msgs_ct = 0 for msg in relevant_messages: @@ -28,23 +25,16 @@ async def apply( if ev_msgs_ct > config["max"]: # Send the user an embed giving them more info: - member_count = "{:,}".format(last_message.guild.member_count).split( - "," - )[0] + member_count = "{:,}".format(last_message.guild.member_count).split(",")[0] # Change the `K` to an `M` once the server reaches over 1 million people. embed_text = textwrap.dedent( f""" - Hello {last_message.author.display_name}, please don't try to ping {member_count}K people. + Please don't try to ping {member_count}K people. **It will not have good results.** - If you want to know what it would be like, imagine pinging Greenland. Please don't ping Greenland. """ ) - embed = Embed( - title="Everyone Ping Mute Info", - colour=Colours.soft_red, - description=embed_text, - ) - await send_private_embed(last_message.author, embed) + embed = Embed(description=embed_text, colour=Colours.soft_red) + await last_message.channel.send(f"Hey {last_message.author.mention}!", embed=embed) return ( f"pinged the everyone role {ev_msgs_ct} times in {config['interval']}s", (last_message.author,), -- cgit v1.2.3 From dbb05d95420cdd6ff08231ce7b9c67cc46bf3675 Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Sat, 29 Aug 2020 18:49:02 -0500 Subject: Everyone Ping: Fixed linting error Switched from string.format to f-string for server member count. --- bot/rules/everyone_ping.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 47931caae..8c1b43628 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -25,7 +25,7 @@ async def apply( if ev_msgs_ct > config["max"]: # Send the user an embed giving them more info: - member_count = "{:,}".format(last_message.guild.member_count).split(",")[0] + member_count = f'{last_message.guild.member_count}'.split(",")[0] # Change the `K` to an `M` once the server reaches over 1 million people. embed_text = textwrap.dedent( f""" -- cgit v1.2.3 From 20f0dfd57f3ed711ef46169b9dcf0e8ee57bcfd1 Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Sun, 30 Aug 2020 07:32:44 -0500 Subject: Everyone ping: Changed message, cleaned file Changed the message to say the raw member count, not just thousands. Also cleaned up some unused variables and imports in the file. --- bot/rules/everyone_ping.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 8c1b43628..037d7254e 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -1,4 +1,3 @@ -import logging import textwrap from typing import Dict, Iterable, List, Optional, Tuple @@ -6,9 +5,6 @@ from discord import Embed, Member, Message from bot.constants import Colours -# For embed sender -log = logging.getLogger(__name__) - async def apply( last_message: Message, @@ -25,11 +21,9 @@ async def apply( if ev_msgs_ct > config["max"]: # Send the user an embed giving them more info: - member_count = f'{last_message.guild.member_count}'.split(",")[0] - # Change the `K` to an `M` once the server reaches over 1 million people. embed_text = textwrap.dedent( f""" - Please don't try to ping {member_count}K people. + Please don't try to ping {last_message.guild.member_count} people. **It will not have good results.** """ ) -- cgit v1.2.3 From e7862878bd4233cc9340c00bfb77079c318a0b22 Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Sun, 30 Aug 2020 07:37:57 -0500 Subject: Everyone ping: added formatting to member count Seperated the member count by commas every three digits. --- bot/rules/everyone_ping.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 037d7254e..44e9aade4 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -23,7 +23,7 @@ async def apply( # Send the user an embed giving them more info: embed_text = textwrap.dedent( f""" - Please don't try to ping {last_message.guild.member_count} people. + Please don't try to ping {last_message.guild.member_count:,} people. **It will not have good results.** """ ) -- cgit v1.2.3 From 702ff7e80d859dbc8189e55d1dcf9e3bd5959c7a Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Sun, 30 Aug 2020 08:18:52 -0500 Subject: Everyone Ping: PR Review Changed cryptic variable name. Changed ping response to use `bot.constants.NEGATIVE_REPLIES`. Changed ping repsonse to only ping user once. --- bot/rules/everyone_ping.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 44e9aade4..f3790ba2c 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -1,9 +1,10 @@ +import random import textwrap from typing import Dict, Iterable, List, Optional, Tuple from discord import Embed, Member, Message -from bot.constants import Colours +from bot.constants import Colours, NEGATIVE_REPLIES async def apply( @@ -14,23 +15,27 @@ async def apply( """Detects if a user has sent an '@everyone' ping.""" relevant_messages = tuple(msg for msg in recent_messages if msg.author == last_message.author) - ev_msgs_ct = 0 + everyone_messages_count = 0 for msg in relevant_messages: if "@everyone" in msg.content: - ev_msgs_ct += 1 + everyone_messages_count += 1 - if ev_msgs_ct > config["max"]: + if everyone_messages_count > config["max"]: # Send the user an embed giving them more info: embed_text = textwrap.dedent( f""" + **{random.choice(NEGATIVE_REPLIES)}** Please don't try to ping {last_message.guild.member_count:,} people. - **It will not have good results.** - """ + """ ) + + # Make embed: embed = Embed(description=embed_text, colour=Colours.soft_red) - await last_message.channel.send(f"Hey {last_message.author.mention}!", embed=embed) + + # Send embed: + await last_message.channel.send(embed=embed) return ( - f"pinged the everyone role {ev_msgs_ct} times in {config['interval']}s", + f"pinged the everyone role {everyone_messages_count} times in {config['interval']}s", (last_message.author,), relevant_messages, ) -- cgit v1.2.3 From 94b89a867942d98138f43ec8d2e6bf8f6607c240 Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Sun, 30 Aug 2020 09:52:17 -0500 Subject: Everyone Ping: NEGATIVE_REPLIES in title The NEGATIVE_REPLIES header is now the title of the embed. --- bot/rules/everyone_ping.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index f3790ba2c..08415b1e0 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -1,5 +1,4 @@ import random -import textwrap from typing import Dict, Iterable, List, Optional, Tuple from discord import Embed, Member, Message @@ -22,15 +21,10 @@ async def apply( if everyone_messages_count > config["max"]: # Send the user an embed giving them more info: - embed_text = textwrap.dedent( - f""" - **{random.choice(NEGATIVE_REPLIES)}** - Please don't try to ping {last_message.guild.member_count:,} people. - """ - ) + embed_text = f"Please don't try to ping {last_message.guild.member_count:,} people." # Make embed: - embed = Embed(description=embed_text, colour=Colours.soft_red) + embed = Embed(title=random.choice(NEGATIVE_REPLIES), description=embed_text, colour=Colours.soft_red) # Send embed: await last_message.channel.send(embed=embed) -- cgit v1.2.3 From a61d0321c4b7a6e137ccb59d8a3af0428838778c Mon Sep 17 00:00:00 2001 From: Dennis Pham Date: Sun, 30 Aug 2020 17:35:10 -0400 Subject: Allow moderators to use defcon --- bot/cogs/defcon.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index de0f4545e..9087ac454 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -10,7 +10,7 @@ from discord.ext.commands import Cog, Context, group from bot.bot import Bot from bot.cogs.moderation import ModLog -from bot.constants import Channels, Colours, Emojis, Event, Icons, Roles +from bot.constants import Channels, Colours, Emojis, Event, Icons, MODERATION_ROLES, Roles from bot.decorators import with_role log = logging.getLogger(__name__) @@ -119,7 +119,7 @@ class Defcon(Cog): ) @group(name='defcon', aliases=('dc',), invoke_without_command=True) - @with_role(Roles.admins, Roles.owners) + @with_role(*MODERATION_ROLES) async def defcon_group(self, ctx: Context) -> None: """Check the DEFCON status or run a subcommand.""" await ctx.send_help(ctx.command) @@ -163,7 +163,7 @@ class Defcon(Cog): self.bot.stats.gauge("defcon.threshold", days) @defcon_group.command(name='enable', aliases=('on', 'e'), root_aliases=("defon",)) - @with_role(Roles.admins, Roles.owners) + @with_role(*MODERATION_ROLES) async def enable_command(self, ctx: Context) -> None: """ Enable DEFCON mode. Useful in a pinch, but be sure you know what you're doing! @@ -176,7 +176,7 @@ class Defcon(Cog): await self.update_channel_topic() @defcon_group.command(name='disable', aliases=('off', 'd'), root_aliases=("defoff",)) - @with_role(Roles.admins, Roles.owners) + @with_role(*MODERATION_ROLES) async def disable_command(self, ctx: Context) -> None: """Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!""" self.enabled = False @@ -184,7 +184,7 @@ class Defcon(Cog): await self.update_channel_topic() @defcon_group.command(name='status', aliases=('s',)) - @with_role(Roles.admins, Roles.owners) + @with_role(*MODERATION_ROLES) async def status_command(self, ctx: Context) -> None: """Check the current status of DEFCON mode.""" embed = Embed( @@ -196,7 +196,7 @@ class Defcon(Cog): await ctx.send(embed=embed) @defcon_group.command(name='days') - @with_role(Roles.admins, Roles.owners) + @with_role(*MODERATION_ROLES) async def days_command(self, ctx: Context, days: int) -> None: """Set how old an account must be to join the server, in days, with DEFCON mode enabled.""" self.days = timedelta(days=days) -- cgit v1.2.3 From 94dcfa584599301f0cfb9e47d4ef9f7f40bdc23c Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Sun, 30 Aug 2020 21:55:39 -0500 Subject: Everyone Ping: PR Review 2 Removed redundant comments. Switched to regex to avoid punishing users for putting `@everyone` in codeblocks. Changed log message since this isn't a anti-spam rule based off of frequency. Added check for `<@&{guild_id}>` ping, also checks for codeblocks. --- bot/rules/everyone_ping.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 08415b1e0..3a8174e44 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -1,9 +1,15 @@ import random +import re from typing import Dict, Iterable, List, Optional, Tuple from discord import Embed, Member, Message -from bot.constants import Colours, NEGATIVE_REPLIES +from bot.constants import Colours, Guild, NEGATIVE_REPLIES + +# Generate regex for checking for pings: +guild_id = Guild.id +EVERYONE_RE_INLINE_CODE = re.compile(rf"(?!`)@everyone(?!`)|(?!`)<@&{guild_id}>(?!`)") +EVERYONE_RE_MULTILINE_CODE = re.compile(rf"(?!```\n.*)@everyone(?!\n.*```)|(?!```\n.*)<@&{guild_id}>(?!\n.*```)") async def apply( @@ -16,20 +22,19 @@ async def apply( everyone_messages_count = 0 for msg in relevant_messages: - if "@everyone" in msg.content: + num_everyone_pings_inline = len(re.findall(EVERYONE_RE_INLINE_CODE, msg.content)) + num_everyone_pings_multiline = len(re.findall(EVERYONE_RE_MULTILINE_CODE, msg.content)) + if num_everyone_pings_inline and num_everyone_pings_multiline: everyone_messages_count += 1 if everyone_messages_count > config["max"]: - # Send the user an embed giving them more info: + # Send the channel an embed giving the user more info: embed_text = f"Please don't try to ping {last_message.guild.member_count:,} people." - - # Make embed: embed = Embed(title=random.choice(NEGATIVE_REPLIES), description=embed_text, colour=Colours.soft_red) - - # Send embed: await last_message.channel.send(embed=embed) + return ( - f"pinged the everyone role {everyone_messages_count} times in {config['interval']}s", + "pinged the everyone role", (last_message.author,), relevant_messages, ) -- cgit v1.2.3 From 9c52a99a03777cdfd728f354cdb305398791eac1 Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Mon, 31 Aug 2020 08:17:57 -0500 Subject: Everyone Ping: Regex Fix Changed the regex to not punish users who have text other than `@everyone` in their codeblocks. Multiline codeblocks can now have `@everyone` in them. --- bot/rules/everyone_ping.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 3a8174e44..560a9ec14 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -8,8 +8,8 @@ from bot.constants import Colours, Guild, NEGATIVE_REPLIES # Generate regex for checking for pings: guild_id = Guild.id -EVERYONE_RE_INLINE_CODE = re.compile(rf"(?!`)@everyone(?!`)|(?!`)<@&{guild_id}>(?!`)") -EVERYONE_RE_MULTILINE_CODE = re.compile(rf"(?!```\n.*)@everyone(?!\n.*```)|(?!```\n.*)<@&{guild_id}>(?!\n.*```)") +EVERYONE_RE_INLINE_CODE = re.compile(rf"^(?!`)@everyone(?!`)$|^(?!`)<@&{guild_id}>(?!`)$") +EVERYONE_RE_MULTILINE_CODE = re.compile(rf"^(?!```)@everyone(?!```)$|^(?!```)<@&{guild_id}>(?!```)$") async def apply( -- cgit v1.2.3 From 4906406cedaa476b8eb1665bc0e20616c91d7f6b Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Mon, 31 Aug 2020 19:19:57 -0500 Subject: Everyone Ping: Fixed regex to catch *all* pings --- bot/rules/everyone_ping.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 560a9ec14..89d9fe570 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -8,8 +8,8 @@ from bot.constants import Colours, Guild, NEGATIVE_REPLIES # Generate regex for checking for pings: guild_id = Guild.id -EVERYONE_RE_INLINE_CODE = re.compile(rf"^(?!`)@everyone(?!`)$|^(?!`)<@&{guild_id}>(?!`)$") -EVERYONE_RE_MULTILINE_CODE = re.compile(rf"^(?!```)@everyone(?!```)$|^(?!```)<@&{guild_id}>(?!```)$") +EVERYONE_RE_INLINE_CODE = re.compile(rf"^(?!`).*@everyone.*(?!`)$|^(?!`).*<@&{guild_id}>.*(?!`)$") +EVERYONE_RE_MULTILINE_CODE = re.compile(rf"^(?!```).*@everyone.*(?!```)$|^(?!```).*<@&{guild_id}>.*(?!```)$") async def apply( -- cgit v1.2.3 From 9bf8e5e394b5f9a8735f235cafe0fd2526be6ab2 Mon Sep 17 00:00:00 2001 From: Xithrius Date: Mon, 31 Aug 2020 19:49:38 -0700 Subject: Removed image pagination utility. --- bot/pagination.py | 164 ------------------------------------------------------ 1 file changed, 164 deletions(-) diff --git a/bot/pagination.py b/bot/pagination.py index bab98cacf..182b2fa76 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -374,167 +374,3 @@ class LinePaginator(Paginator): log.debug("Ending pagination and clearing reactions.") with suppress(discord.NotFound): await message.clear_reactions() - - -class ImagePaginator(Paginator): - """ - Helper class that paginates images for embeds in messages. - - Close resemblance to LinePaginator, except focuses on images over text. - - Refer to ImagePaginator.paginate for documentation on how to use. - """ - - def __init__(self, prefix: str = "", suffix: str = ""): - super().__init__(prefix, suffix) - self._current_page = [prefix] - self.images = [] - self._pages = [] - self._count = 0 - - def add_line(self, line: str = '', *, empty: bool = False) -> None: - """Adds a line to each page.""" - if line: - self._count = len(line) - else: - self._count = 0 - self._current_page.append(line) - self.close_page() - - def add_image(self, image: str = None) -> None: - """Adds an image to a page.""" - self.images.append(image) - - @classmethod - async def paginate( - cls, - pages: t.List[t.Tuple[str, str]], - ctx: Context, embed: discord.Embed, - prefix: str = "", - suffix: str = "", - timeout: int = 300, - exception_on_empty_embed: bool = False - ) -> t.Optional[discord.Message]: - """ - Use a paginator and set of reactions to provide pagination over a set of title/image pairs. - - The reactions are used to switch page, or to finish with pagination. - - When used, this will send a message using `ctx.send()` and apply a set of reactions to it. These reactions may - be used to change page, or to remove pagination from the message. - - Note: Pagination will be removed automatically if no reaction is added for five minutes (300 seconds). - - Example: - >>> embed = discord.Embed() - >>> embed.set_author(name="Some Operation", url=url, icon_url=icon) - >>> await ImagePaginator.paginate(pages, ctx, embed) - """ - def check_event(reaction_: discord.Reaction, member: discord.Member) -> bool: - """Checks each reaction added, if it matches our conditions pass the wait_for.""" - return all(( - # Reaction is on the same message sent - reaction_.message.id == message.id, - # The reaction is part of the navigation menu - str(reaction_.emoji) in PAGINATION_EMOJI, - # The reactor is not a bot - not member.bot - )) - - paginator = cls(prefix=prefix, suffix=suffix) - current_page = 0 - - if not pages: - if exception_on_empty_embed: - log.exception("Pagination asked for empty image list") - raise EmptyPaginatorEmbed("No images to paginate") - - log.debug("No images to add to paginator, adding '(no images to display)' message") - pages.append(("(no images to display)", "")) - - for text, image_url in pages: - paginator.add_line(text) - paginator.add_image(image_url) - - embed.description = paginator.pages[current_page] - image = paginator.images[current_page] - - if image: - embed.set_image(url=image) - - if len(paginator.pages) <= 1: - return await ctx.send(embed=embed) - - embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") - message = await ctx.send(embed=embed) - - for emoji in PAGINATION_EMOJI: - await message.add_reaction(emoji) - - while True: - # Start waiting for reactions - try: - reaction, user = await ctx.bot.wait_for("reaction_add", timeout=timeout, check=check_event) - except asyncio.TimeoutError: - log.debug("Timed out waiting for a reaction") - break # We're done, no reactions for the last 5 minutes - - # Deletes the users reaction - await message.remove_reaction(reaction.emoji, user) - - # Delete reaction press - [:trashcan:] - if str(reaction.emoji) == DELETE_EMOJI: - log.debug("Got delete reaction") - return await message.delete() - - # First reaction press - [:track_previous:] - if reaction.emoji == FIRST_EMOJI: - if current_page == 0: - log.debug("Got first page reaction, but we're on the first page - ignoring") - continue - - current_page = 0 - reaction_type = "first" - - # Last reaction press - [:track_next:] - if reaction.emoji == LAST_EMOJI: - if current_page >= len(paginator.pages) - 1: - log.debug("Got last page reaction, but we're on the last page - ignoring") - continue - - current_page = len(paginator.pages) - 1 - reaction_type = "last" - - # Previous reaction press - [:arrow_left: ] - if reaction.emoji == LEFT_EMOJI: - if current_page <= 0: - log.debug("Got previous page reaction, but we're on the first page - ignoring") - continue - - current_page -= 1 - reaction_type = "previous" - - # Next reaction press - [:arrow_right:] - if reaction.emoji == RIGHT_EMOJI: - if current_page >= len(paginator.pages) - 1: - log.debug("Got next page reaction, but we're on the last page - ignoring") - continue - - current_page += 1 - reaction_type = "next" - - # Magic happens here, after page and reaction_type is set - embed.description = paginator.pages[current_page] - - image = paginator.images[current_page] - if image: - embed.set_image(url=image) - - embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") - log.debug(f"Got {reaction_type} page reaction - changing to page {current_page + 1}/{len(paginator.pages)}") - - await message.edit(embed=embed) - - log.debug("Ending pagination and clearing reactions.") - with suppress(discord.NotFound): - await message.clear_reactions() -- cgit v1.2.3 From b7644aa822def549e2591b53c69af3cf44355ac9 Mon Sep 17 00:00:00 2001 From: Xithrius Date: Mon, 31 Aug 2020 19:56:24 -0700 Subject: Removed ImagePaginator testing. --- tests/bot/test_pagination.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/tests/bot/test_pagination.py b/tests/bot/test_pagination.py index ce880d457..630f2516d 100644 --- a/tests/bot/test_pagination.py +++ b/tests/bot/test_pagination.py @@ -44,18 +44,3 @@ class LinePaginatorTests(TestCase): self.paginator.add_line('x' * (self.paginator.scale_to_size + 1)) # Note: item at index 1 is the truncated line, index 0 is prefix self.assertEqual(self.paginator._current_page[1], 'x' * self.paginator.scale_to_size) - - -class ImagePaginatorTests(TestCase): - """Tests functionality of the `ImagePaginator`.""" - - def setUp(self): - """Create a paginator for the test method.""" - self.paginator = pagination.ImagePaginator() - - def test_add_image_appends_image(self): - """`add_image` appends the image to the image list.""" - image = 'lemon' - self.paginator.add_image(image) - - assert self.paginator.images == [image] -- cgit v1.2.3 From 10181ab4dc8711c561caca0a2fbc40ff4c4ecf6c Mon Sep 17 00:00:00 2001 From: Xithrius Date: Mon, 31 Aug 2020 20:40:45 -0700 Subject: Removed loading of the Wolfram cog. --- bot/__main__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/__main__.py b/bot/__main__.py index f698b5662..fe2cf90e6 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -74,7 +74,6 @@ bot.load_extension("bot.cogs.token_remover") bot.load_extension("bot.cogs.utils") bot.load_extension("bot.cogs.watchchannels") bot.load_extension("bot.cogs.webhook_remover") -bot.load_extension("bot.cogs.wolfram") if constants.HelpChannels.enable: bot.load_extension("bot.cogs.help_channels") -- cgit v1.2.3 From 03ab17b9383a57591b2f82a0526188efd902f61b Mon Sep 17 00:00:00 2001 From: wookie184 Date: Tue, 1 Sep 2020 11:27:29 +0100 Subject: Added checks to ignore webhook and bot messages --- bot/cogs/antimalware.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index c76bd2c60..7894ec48f 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -55,6 +55,10 @@ class AntiMalware(Cog): if not message.attachments or not message.guild: return + # Ignore webhook and bot messages + if message.webhook_id or message.author.bot: + return + # Check if user is staff, if is, return # Since we only care that roles exist to iterate over, check for the attr rather than a User/Member instance if hasattr(message.author, "roles") and any(role.id in STAFF_ROLES for role in message.author.roles): -- cgit v1.2.3 From 1a47f5d80f2f91c3da5a9626e9a6694381d49cd0 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Tue, 1 Sep 2020 12:22:43 +0100 Subject: Fixed old tests and added 2 new ones --- tests/bot/cogs/test_antimalware.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/bot/cogs/test_antimalware.py b/tests/bot/cogs/test_antimalware.py index ecb7abf00..f50c0492d 100644 --- a/tests/bot/cogs/test_antimalware.py +++ b/tests/bot/cogs/test_antimalware.py @@ -23,6 +23,8 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): } self.cog = antimalware.AntiMalware(self.bot) self.message = MockMessage() + self.message.webhook_id = None + self.message.author.bot = None self.whitelist = [".first", ".second", ".third"] async def test_message_with_allowed_attachment(self): @@ -48,6 +50,26 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): self.message.delete.assert_not_called() + async def test_webhook_message_with_illegal_extension(self): + """A webhook message containing an illegal extension should be ignored.""" + attachment = MockAttachment(filename="python.disallowed") + self.message.webhook_id = 697140105563078727 + self.message.attachments = [attachment] + + await self.cog.on_message(self.message) + + self.message.delete.assert_not_called() + + async def test_bot_message_with_illegal_extension(self): + """A bot message containing an illegal extension should be ignored.""" + attachment = MockAttachment(filename="python.disallowed") + self.message.author.bot = 409107086526644234 + self.message.attachments = [attachment] + + await self.cog.on_message(self.message) + + self.message.delete.assert_not_called() + async def test_message_with_illegal_extension_gets_deleted(self): """A message containing an illegal extension should send an embed.""" attachment = MockAttachment(filename="python.disallowed") -- cgit v1.2.3 From 203abc99ad104ddef068ef2ddddff37a9982c47d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 2 Sep 2020 13:42:45 -0700 Subject: Constants: remove staff_channels No longer being used anywhere. --- bot/constants.py | 2 -- config-default.yml | 9 --------- 2 files changed, 11 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index f3db80279..b3825ea02 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -472,7 +472,6 @@ class Guild(metaclass=YAMLGetter): moderation_roles: List[int] modlog_blacklist: List[int] reminder_whitelist: List[int] - staff_channels: List[int] staff_roles: List[int] @@ -624,7 +623,6 @@ MODERATION_ROLES = Guild.moderation_roles STAFF_ROLES = Guild.staff_roles # Channel combinations -STAFF_CHANNELS = Guild.staff_channels MODERATION_CHANNELS = Guild.moderation_channels # Bot replies diff --git a/config-default.yml b/config-default.yml index 8c0092e76..9d29b9a96 100644 --- a/config-default.yml +++ b/config-default.yml @@ -199,15 +199,6 @@ guild: big_brother_logs: &BB_LOGS 468507907357409333 talent_pool: &TALENT_POOL 534321732593647616 - staff_channels: - - *ADMINS - - *ADMIN_SPAM - - *DEFCON - - *HELPERS - - *MODS - - *MOD_SPAM - - *ORGANISATION - moderation_channels: - *ADMINS - *ADMIN_SPAM -- cgit v1.2.3 From 1512dcc994dfacd0995a93320efc001550f15212 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Fri, 4 Sep 2020 20:05:03 +0200 Subject: Disable burst_shared filter of the AntiSpam cog Our AntiSpam cog suffers from a race condition that causes it to try and infract the same user multiple times. As that happens frequently with the burst_shared filter, it means that our bot joins in and starts spamming the channel with error messages. Another issue is that burst_shared may cause our bot to send a lot of DMs to a lot of different members. This caused our bot to get a DM ban from Discord after a recent `everyone` ping incident. I've decided to disable the `burst_shared` filter by commenting out the relevant lines but leave the code in place otherwise. This means we still have the implementation handy in case we want to re-enable it on short notice. Signed-off-by: Sebastiaan Zeeff --- bot/cogs/antispam.py | 3 ++- config-default.yml | 10 +++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index d003f962b..b8939113f 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -27,7 +27,8 @@ log = logging.getLogger(__name__) RULE_FUNCTION_MAPPING = { 'attachments': rules.apply_attachments, 'burst': rules.apply_burst, - 'burst_shared': rules.apply_burst_shared, + # burst shared is temporarily disabled due to a bug + # 'burst_shared': rules.apply_burst_shared, 'chars': rules.apply_chars, 'discord_emojis': rules.apply_discord_emojis, 'duplicates': rules.apply_duplicates, diff --git a/config-default.yml b/config-default.yml index 766f7050c..e9324c62f 100644 --- a/config-default.yml +++ b/config-default.yml @@ -352,9 +352,13 @@ anti_spam: interval: 10 max: 7 - burst_shared: - interval: 10 - max: 20 + # Burst shared it (temporarily) disabled to prevent + # the bug that triggers multiple infractions/DMs per + # user. It also tends to catch a lot of innocent users + # now that we're so big. + # burst_shared: + # interval: 10 + # max: 20 chars: interval: 5 -- cgit v1.2.3 From d2e7dd3763d24a2224fe0eefd78852e2a2389850 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Fri, 4 Sep 2020 20:25:26 +0200 Subject: Move bolding markdown outside of text link. On some devices the markdown gets rendered improperly, leaving the asterisks in the message without bolding. --- bot/cogs/help_channels.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 541c6f336..0f9cac89e 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -36,7 +36,7 @@ the **Help: Dormant** category. Try to write the best question you can by providing a detailed description and telling us what \ you've tried already. For more information on asking a good question, \ -check out our guide on [**asking good questions**]({ASKING_GUIDE_URL}). +check out our guide on **[asking good questions]({ASKING_GUIDE_URL})**. """ DORMANT_MSG = f""" @@ -47,7 +47,7 @@ channel until it becomes available again. If your question wasn't answered yet, you can claim a new help channel from the \ **Help: Available** category by simply asking your question again. Consider rephrasing the \ question to maximize your chance of getting a good answer. If you're not sure how, have a look \ -through our guide for [**asking a good question**]({ASKING_GUIDE_URL}). +through our guide for **[asking a good question]({ASKING_GUIDE_URL})**. """ CoroutineFunc = t.Callable[..., t.Coroutine] -- cgit v1.2.3 From 0351a23513ccf5d9d8ab0637a0bfc4043796b0dc Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sat, 5 Sep 2020 09:01:45 +0800 Subject: Detect pings after removing codeblocks. --- bot/rules/everyone_ping.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 89d9fe570..8fc03b924 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -8,8 +8,12 @@ from bot.constants import Colours, Guild, NEGATIVE_REPLIES # Generate regex for checking for pings: guild_id = Guild.id -EVERYONE_RE_INLINE_CODE = re.compile(rf"^(?!`).*@everyone.*(?!`)$|^(?!`).*<@&{guild_id}>.*(?!`)$") -EVERYONE_RE_MULTILINE_CODE = re.compile(rf"^(?!```).*@everyone.*(?!```)$|^(?!```).*<@&{guild_id}>.*(?!```)$") +EVERYONE_PING_RE = re.compile(rf"@everyone|<@&{guild_id}>") +CODE_BLOCK_RE = re.compile( + r"(?P``?)[^`]+?(?P=delim)(?!`+)" # Inline codeblock + r"|```(.+?)```", # Multiline codeblock + re.DOTALL | re.MULTILINE +) async def apply( @@ -22,10 +26,9 @@ async def apply( everyone_messages_count = 0 for msg in relevant_messages: - num_everyone_pings_inline = len(re.findall(EVERYONE_RE_INLINE_CODE, msg.content)) - num_everyone_pings_multiline = len(re.findall(EVERYONE_RE_MULTILINE_CODE, msg.content)) - if num_everyone_pings_inline and num_everyone_pings_multiline: - everyone_messages_count += 1 + content = CODE_BLOCK_RE.sub("", msg.content) # Remove codeblocks in the message + if matches := len(EVERYONE_PING_RE.findall(content)): + everyone_messages_count += matches if everyone_messages_count > config["max"]: # Send the channel an embed giving the user more info: -- cgit v1.2.3 From 3189afa9a02fc0333b4d814a48f17122af768345 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sat, 5 Sep 2020 09:03:46 +0800 Subject: Add test for everyone_ping rule. --- tests/bot/rules/test_everyone_ping.py | 102 ++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 tests/bot/rules/test_everyone_ping.py diff --git a/tests/bot/rules/test_everyone_ping.py b/tests/bot/rules/test_everyone_ping.py new file mode 100644 index 000000000..3ecc43cdc --- /dev/null +++ b/tests/bot/rules/test_everyone_ping.py @@ -0,0 +1,102 @@ +from typing import Iterable + +from bot.rules import everyone_ping +from tests.bot.rules import DisallowedCase, RuleTest +from tests.helpers import MockGuild, MockMessage + +NUM_GUILD_MEMBERS = 100 + + +def make_msg(author: str, message: str) -> MockMessage: + """Build a message with `message` as the content sent.""" + mocked_guild = MockGuild(member_count=NUM_GUILD_MEMBERS) + return MockMessage(author=author, content=message, guild=mocked_guild) + + +class EveryonePingRuleTest(RuleTest): + """Tests the `everyone_ping` antispam rule.""" + + def setUp(self): + self.apply = everyone_ping.apply + self.config = { + "max": 0, # Max allowed @everyone pings per user + "interval": 10, + } + + async def test_disallows_everyone_ping(self): + """Cases with an @everyone ping.""" + cases = ( + DisallowedCase( + [make_msg("bob", "@everyone")], + ("bob",), + 1 + ), + DisallowedCase( + [make_msg("bob", "Let me ping @everyone in the server.")], + ("bob",), + 1 + ), + DisallowedCase( + [make_msg("bob", "`codeblock message` and @everyone ping")], + ("bob",), + 1 + ), + DisallowedCase( + [make_msg("bob", "`sandwich` @everyone `ping between codeblocks`.")], + ("bob",), + 1 + ), + DisallowedCase( + [make_msg("bob", "This is a multiline\n@everyone\nping.")], + ("bob",), + 1 + ), + # Not actually valid code blocks + DisallowedCase( + [make_msg("bob", "`@everyone``")], + ("bob",), + 1 + ), + DisallowedCase( + [make_msg("bob", "`@everyone``````")], + ("bob",), + 1 + ), + DisallowedCase( + [make_msg("bob", "``@everyone``````")], + ("bob",), + 1 + ), + ) + + await self.run_disallowed(cases) + + async def test_allows_inline_codeblock_everyone_ping(self): + """Cases with an @everyone ping in an inline codeblock.""" + cases = ( + [make_msg("bob", "Codeblock has `@everyone` ping.")], + [make_msg("bob", "Multiple `codeblocks` including `@everyone` ping.")], + [make_msg("bob", "This is a valid ``inline @everyone` ping.")], + ) + + await self.run_allowed(cases) + + async def test_allows_multiline_codeblock_everyone_ping(self): + """Cases with an @everyone ping in a multiline codeblock.""" + cases = ( + [make_msg("bob", "```Multiline codeblock has\nan `@everyone` ping.```")], + [make_msg("bob", "``` `@everyone``` ` `")], + ) + + await self.run_allowed(cases) + + def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: + last_message = case.recent_messages[0] + return tuple( + msg + for msg in case.recent_messages + if msg.author == last_message.author + ) + + def get_report(self, case: DisallowedCase) -> str: + return "pinged the everyone role" -- cgit v1.2.3 From 53cb77bb2d541d0be61bc3c25e37b54601963b7c Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sat, 5 Sep 2020 11:07:58 +0200 Subject: Disable everyone_ping filter in AntiSpam cog As there are a few bugs in the implementation, I've temporarily disabled the at-everyone ping filter in the AntiSpam cog. We can disable it after we've fixed the bugs. Signed-off-by: Sebastiaan Zeeff --- bot/cogs/antispam.py | 4 +++- config-default.yml | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index b8939113f..3ad487d8c 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -36,7 +36,9 @@ RULE_FUNCTION_MAPPING = { 'mentions': rules.apply_mentions, 'newlines': rules.apply_newlines, 'role_mentions': rules.apply_role_mentions, - 'everyone_ping': rules.apply_everyone_ping, + # the everyone filter is temporarily disabled until + # it has been improved. + # 'everyone_ping': rules.apply_everyone_ping, } diff --git a/config-default.yml b/config-default.yml index e9324c62f..6e7cff92d 100644 --- a/config-default.yml +++ b/config-default.yml @@ -389,9 +389,11 @@ anti_spam: interval: 10 max: 3 - everyone_ping: - interval: 10 - max: 0 + # The everyone ping filter is temporarily disabled + # until we've fixed a couple of bugs. + # everyone_ping: + # interval: 10 + # max: 0 reddit: -- cgit v1.2.3 From 8c39b74fa757e5770ee74bb8013b23dc1347a0d5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 5 Sep 2020 19:16:54 -0700 Subject: Remove with_role decorator in favour of has_any_role `with_role` is obsolete because discord.py provides `has_any_role`. --- bot/cogs/bot.py | 11 +++++------ bot/cogs/clean.py | 17 ++++++++--------- bot/cogs/defcon.py | 13 ++++++------- bot/cogs/doc.py | 7 +++---- bot/cogs/eval.py | 7 +++---- bot/cogs/information.py | 8 ++++---- bot/cogs/jams.py | 3 +-- bot/cogs/off_topic_names.py | 15 +++++++-------- bot/cogs/reddit.py | 5 ++--- bot/cogs/utils.py | 6 +++--- bot/cogs/watchchannels/bigbrother.py | 13 ++++++------- bot/cogs/watchchannels/talentpool.py | 19 +++++++++---------- bot/decorators.py | 10 +--------- 13 files changed, 58 insertions(+), 76 deletions(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index ddd1cef8d..6b8269729 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -5,13 +5,12 @@ import time from typing import Optional, Tuple from discord import Embed, Message, RawMessageUpdateEvent, TextChannel -from discord.ext.commands import Cog, Context, command, group +from discord.ext.commands import Cog, Context, command, group, has_any_role from bot.bot import Bot from bot.cogs.token_remover import TokenRemover from bot.cogs.webhook_remover import WEBHOOK_URL_RE from bot.constants import Categories, Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs -from bot.decorators import with_role from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) @@ -39,13 +38,13 @@ class BotCog(Cog, name="Bot"): self.codeblock_message_ids = {} @group(invoke_without_command=True, name="bot", hidden=True) - @with_role(Roles.verified) + @has_any_role(Roles.verified) async def botinfo_group(self, ctx: Context) -> None: """Bot informational commands.""" await ctx.send_help(ctx.command) @botinfo_group.command(name='about', aliases=('info',), hidden=True) - @with_role(Roles.verified) + @has_any_role(Roles.verified) async def about_command(self, ctx: Context) -> None: """Get information about the bot.""" embed = Embed( @@ -63,7 +62,7 @@ class BotCog(Cog, name="Bot"): await ctx.send(embed=embed) @command(name='echo', aliases=('print',)) - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def echo_command(self, ctx: Context, channel: Optional[TextChannel], *, text: str) -> None: """Repeat the given message in either a specified channel or the current channel.""" if channel is None: @@ -72,7 +71,7 @@ class BotCog(Cog, name="Bot"): await channel.send(text) @command(name='embed') - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def embed_command(self, ctx: Context, channel: Optional[TextChannel], *, text: str) -> None: """Send the input within an embed to either a specified channel or the current channel.""" embed = Embed(description=text) diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index f436e531a..7f8873e36 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -5,14 +5,13 @@ from typing import Iterable, Optional from discord import Colour, Embed, Message, TextChannel, User from discord.ext import commands -from discord.ext.commands import Cog, Context, group +from discord.ext.commands import Cog, Context, group, has_any_role from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.constants import ( Channels, CleanMessages, Colours, Event, Icons, MODERATION_ROLES, NEGATIVE_REPLIES ) -from bot.decorators import with_role log = logging.getLogger(__name__) @@ -192,13 +191,13 @@ class Clean(Cog): ) @group(invoke_without_command=True, name="clean", aliases=["purge"]) - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def clean_group(self, ctx: Context) -> None: """Commands for cleaning messages in channels.""" await ctx.send_help(ctx.command) @clean_group.command(name="user", aliases=["users"]) - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def clean_user( self, ctx: Context, @@ -210,7 +209,7 @@ class Clean(Cog): await self._clean_messages(amount, ctx, user=user, channels=channels) @clean_group.command(name="all", aliases=["everything"]) - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def clean_all( self, ctx: Context, @@ -221,7 +220,7 @@ class Clean(Cog): await self._clean_messages(amount, ctx, channels=channels) @clean_group.command(name="bots", aliases=["bot"]) - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def clean_bots( self, ctx: Context, @@ -232,7 +231,7 @@ class Clean(Cog): await self._clean_messages(amount, ctx, bots_only=True, channels=channels) @clean_group.command(name="regex", aliases=["word", "expression"]) - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def clean_regex( self, ctx: Context, @@ -244,7 +243,7 @@ class Clean(Cog): await self._clean_messages(amount, ctx, regex=regex, channels=channels) @clean_group.command(name="message", aliases=["messages"]) - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def clean_message(self, ctx: Context, message: Message) -> None: """Delete all messages until certain message, stop cleaning after hitting the `message`.""" await self._clean_messages( @@ -255,7 +254,7 @@ class Clean(Cog): ) @clean_group.command(name="stop", aliases=["cancel", "abort"]) - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def clean_cancel(self, ctx: Context) -> None: """If there is an ongoing cleaning process, attempt to immediately cancel it.""" self.cleaning = False diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 9087ac454..64d47c6c6 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -6,12 +6,11 @@ from datetime import datetime, timedelta from enum import Enum from discord import Colour, Embed, Member -from discord.ext.commands import Cog, Context, group +from discord.ext.commands import Cog, Context, group, has_any_role from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.constants import Channels, Colours, Emojis, Event, Icons, MODERATION_ROLES, Roles -from bot.decorators import with_role log = logging.getLogger(__name__) @@ -119,7 +118,7 @@ class Defcon(Cog): ) @group(name='defcon', aliases=('dc',), invoke_without_command=True) - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def defcon_group(self, ctx: Context) -> None: """Check the DEFCON status or run a subcommand.""" await ctx.send_help(ctx.command) @@ -163,7 +162,7 @@ class Defcon(Cog): self.bot.stats.gauge("defcon.threshold", days) @defcon_group.command(name='enable', aliases=('on', 'e'), root_aliases=("defon",)) - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def enable_command(self, ctx: Context) -> None: """ Enable DEFCON mode. Useful in a pinch, but be sure you know what you're doing! @@ -176,7 +175,7 @@ class Defcon(Cog): await self.update_channel_topic() @defcon_group.command(name='disable', aliases=('off', 'd'), root_aliases=("defoff",)) - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def disable_command(self, ctx: Context) -> None: """Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!""" self.enabled = False @@ -184,7 +183,7 @@ class Defcon(Cog): await self.update_channel_topic() @defcon_group.command(name='status', aliases=('s',)) - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def status_command(self, ctx: Context) -> None: """Check the current status of DEFCON mode.""" embed = Embed( @@ -196,7 +195,7 @@ class Defcon(Cog): await ctx.send(embed=embed) @defcon_group.command(name='days') - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def days_command(self, ctx: Context, days: int) -> None: """Set how old an account must be to join the server, in days, with DEFCON mode enabled.""" self.days = timedelta(days=days) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 30c793c75..e50b9b32b 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -21,7 +21,6 @@ from urllib3.exceptions import ProtocolError from bot.bot import Bot from bot.constants import MODERATION_ROLES, RedirectOutput from bot.converters import ValidPythonIdentifier, ValidURL -from bot.decorators import with_role from bot.pagination import LinePaginator from bot.utils.messages import wait_for_deletion @@ -396,7 +395,7 @@ class Doc(commands.Cog): await wait_for_deletion(msg, (ctx.author.id,), client=self.bot) @docs_group.command(name='set', aliases=('s',)) - @with_role(*MODERATION_ROLES) + @commands.has_any_role(*MODERATION_ROLES) async def set_command( self, ctx: commands.Context, package_name: ValidPythonIdentifier, base_url: ValidURL, inventory_url: InventoryURL @@ -433,7 +432,7 @@ class Doc(commands.Cog): await ctx.send(f"Added package `{package_name}` to database and refreshed inventory.") @docs_group.command(name='delete', aliases=('remove', 'rm', 'd')) - @with_role(*MODERATION_ROLES) + @commands.has_any_role(*MODERATION_ROLES) async def delete_command(self, ctx: commands.Context, package_name: ValidPythonIdentifier) -> None: """ Removes the specified package from the database. @@ -450,7 +449,7 @@ class Doc(commands.Cog): await ctx.send(f"Successfully deleted `{package_name}` and refreshed inventory.") @docs_group.command(name="refresh", aliases=("rfsh", "r")) - @with_role(*MODERATION_ROLES) + @commands.has_any_role(*MODERATION_ROLES) async def refresh_command(self, ctx: commands.Context) -> None: """Refresh inventories and send differences to channel.""" old_inventories = set(self.base_urls) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index eb8bfb1cf..468831365 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -9,11 +9,10 @@ from io import StringIO from typing import Any, Optional, Tuple import discord -from discord.ext.commands import Cog, Context, group +from discord.ext.commands import Cog, Context, group, has_any_role from bot.bot import Bot from bot.constants import Roles -from bot.decorators import with_role from bot.interpreter import Interpreter log = logging.getLogger(__name__) @@ -174,14 +173,14 @@ async def func(): # (None,) -> Any await ctx.send(f"```py\n{out}```", embed=embed) @group(name='internal', aliases=('int',)) - @with_role(Roles.owners, Roles.admins) + @has_any_role(Roles.owners, Roles.admins) async def internal_group(self, ctx: Context) -> None: """Internal commands. Top secret!""" if not ctx.invoked_subcommand: await ctx.send_help(ctx.command) @internal_group.command(name='eval', aliases=('e',)) - @with_role(Roles.admins, Roles.owners) + @has_any_role(Roles.admins, Roles.owners) async def eval(self, ctx: Context, *, code: str) -> None: """Run eval in a REPL-like format.""" code = code.strip("`") diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 55ecb2836..abfbcb84e 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -8,12 +8,12 @@ from typing import Any, Mapping, Optional, Tuple, Union from discord import ChannelType, Colour, CustomActivity, Embed, Guild, Member, Message, Role, Status, utils from discord.abc import GuildChannel -from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group +from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role from discord.utils import escape_markdown from bot import constants from bot.bot import Bot -from bot.decorators import in_whitelist, with_role +from bot.decorators import in_whitelist from bot.pagination import LinePaginator from bot.utils.checks import InWhitelistCheckFailure, cooldown_with_role_bypass, with_role_check from bot.utils.time import time_since @@ -76,7 +76,7 @@ class Information(Cog): channel_type_list = sorted(channel_type_list) return "\n".join(channel_type_list) - @with_role(*constants.MODERATION_ROLES) + @has_any_role(*constants.MODERATION_ROLES) @command(name="roles") async def roles_info(self, ctx: Context) -> None: """Returns a list of all roles and their corresponding IDs.""" @@ -96,7 +96,7 @@ class Information(Cog): await LinePaginator.paginate(role_list, ctx, embed, empty=False) - @with_role(*constants.MODERATION_ROLES) + @has_any_role(*constants.MODERATION_ROLES) @command(name="role") async def role_info(self, ctx: Context, *roles: Union[Role, str]) -> None: """ diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py index b3102db2f..1c0988343 100644 --- a/bot/cogs/jams.py +++ b/bot/cogs/jams.py @@ -7,7 +7,6 @@ from more_itertools import unique_everseen from bot.bot import Bot from bot.constants import Roles -from bot.decorators import with_role log = logging.getLogger(__name__) @@ -22,7 +21,7 @@ class CodeJams(commands.Cog): self.bot = bot @commands.command() - @with_role(Roles.admins) + @commands.has_any_role(Roles.admins) async def createteam(self, ctx: commands.Context, team_name: str, members: commands.Greedy[Member]) -> None: """ Create team channels (voice and text) in the Code Jams category, assign roles, and add overwrites for the team. diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index ce95450e0..b9d235fa2 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -4,13 +4,12 @@ import logging from datetime import datetime, timedelta from discord import Colour, Embed -from discord.ext.commands import Cog, Context, group +from discord.ext.commands import Cog, Context, group, has_any_role from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Channels, MODERATION_ROLES from bot.converters import OffTopicName -from bot.decorators import with_role from bot.pagination import LinePaginator CHANNELS = (Channels.off_topic_0, Channels.off_topic_1, Channels.off_topic_2) @@ -67,13 +66,13 @@ class OffTopicNames(Cog): self.updater_task = self.bot.loop.create_task(coro) @group(name='otname', aliases=('otnames', 'otn'), invoke_without_command=True) - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def otname_group(self, ctx: Context) -> None: """Add or list items from the off-topic channel name rotation.""" await ctx.send_help(ctx.command) @otname_group.command(name='add', aliases=('a',)) - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def add_command(self, ctx: Context, *, name: OffTopicName) -> None: """ Adds a new off-topic name to the rotation. @@ -96,7 +95,7 @@ class OffTopicNames(Cog): await self._add_name(ctx, name) @otname_group.command(name='forceadd', aliases=('fa',)) - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def force_add_command(self, ctx: Context, *, name: OffTopicName) -> None: """Forcefully adds a new off-topic name to the rotation.""" await self._add_name(ctx, name) @@ -109,7 +108,7 @@ class OffTopicNames(Cog): await ctx.send(f":ok_hand: Added `{name}` to the names list.") @otname_group.command(name='delete', aliases=('remove', 'rm', 'del', 'd')) - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def delete_command(self, ctx: Context, *, name: OffTopicName) -> None: """Removes a off-topic name from the rotation.""" await self.bot.api_client.delete(f'bot/off-topic-channel-names/{name}') @@ -118,7 +117,7 @@ class OffTopicNames(Cog): await ctx.send(f":ok_hand: Removed `{name}` from the names list.") @otname_group.command(name='list', aliases=('l',)) - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def list_command(self, ctx: Context) -> None: """ Lists all currently known off-topic channel names in a paginator. @@ -138,7 +137,7 @@ class OffTopicNames(Cog): await ctx.send(embed=embed) @otname_group.command(name='search', aliases=('s',)) - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def search_command(self, ctx: Context, *, query: OffTopicName) -> None: """Search for an off-topic name.""" result = await self.bot.api_client.get('bot/off-topic-channel-names') diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 5d9e2c20b..635162308 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -8,14 +8,13 @@ from typing import List from aiohttp import BasicAuth, ClientError from discord import Colour, Embed, TextChannel -from discord.ext.commands import Cog, Context, group +from discord.ext.commands import Cog, Context, group, has_any_role from discord.ext.tasks import loop from discord.utils import escape_markdown from bot.bot import Bot from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES, Webhooks from bot.converters import Subreddit -from bot.decorators import with_role from bot.pagination import LinePaginator from bot.utils.messages import sub_clyde @@ -282,7 +281,7 @@ class Reddit(Cog): await ctx.send(content=f"Here are this week's top {subreddit} posts!", embed=embed) - @with_role(*STAFF_ROLES) + @has_any_role(*STAFF_ROLES) @reddit_group.command(name="subreddits", aliases=("subs",)) async def subreddits_command(self, ctx: Context) -> None: """Send a paginated embed of all the subreddits we're relaying.""" diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index d96abbd5a..6b6941064 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -7,11 +7,11 @@ from io import StringIO from typing import Tuple, Union from discord import Colour, Embed, utils -from discord.ext.commands import BadArgument, Cog, Context, clean_content, command +from discord.ext.commands import BadArgument, Cog, Context, clean_content, command, has_any_role from bot.bot import Bot from bot.constants import Channels, MODERATION_ROLES, STAFF_ROLES -from bot.decorators import in_whitelist, with_role +from bot.decorators import in_whitelist from bot.pagination import LinePaginator from bot.utils import messages @@ -224,7 +224,7 @@ class Utils(Cog): await ctx.send(embed=embed) @command(aliases=("poll",)) - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def vote(self, ctx: Context, title: clean_content(fix_channel_mentions=True), *options: str) -> None: """ Build a quick voting poll with matching reactions with the provided options. diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 11ab8917a..af0354cf8 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -2,13 +2,12 @@ import logging import textwrap from collections import ChainMap -from discord.ext.commands import Cog, Context, group +from discord.ext.commands import Cog, Context, group, has_any_role from bot.bot import Bot from bot.cogs.moderation.utils import post_infraction from bot.constants import Channels, MODERATION_ROLES, Webhooks from bot.converters import FetchedMember -from bot.decorators import with_role from .watchchannel import WatchChannel log = logging.getLogger(__name__) @@ -28,13 +27,13 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): ) @group(name='bigbrother', aliases=('bb',), invoke_without_command=True) - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def bigbrother_group(self, ctx: Context) -> None: """Monitors users by relaying their messages to the Big Brother watch channel.""" await ctx.send_help(ctx.command) @bigbrother_group.command(name='watched', aliases=('all', 'list')) - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def watched_command( self, ctx: Context, oldest_first: bool = False, update_cache: bool = True ) -> None: @@ -49,7 +48,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) @bigbrother_group.command(name='oldest') - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: """ Shows Big Brother monitored users ordered by oldest watched. @@ -60,7 +59,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) @bigbrother_group.command(name='watch', aliases=('w',), root_aliases=('watch',)) - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """ Relay messages sent by the given `user` to the `#big-brother` channel. @@ -71,7 +70,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): await self.apply_watch(ctx, user, reason) @bigbrother_group.command(name='unwatch', aliases=('uw',), root_aliases=('unwatch',)) - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """Stop relaying messages by the given `user`.""" await self.apply_unwatch(ctx, user, reason) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 76d6fe9bd..d0a829f4e 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -4,13 +4,12 @@ from collections import ChainMap from typing import Union from discord import Color, Embed, Member, User -from discord.ext.commands import Cog, Context, group +from discord.ext.commands import Cog, Context, group, has_any_role from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Channels, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks from bot.converters import FetchedMember -from bot.decorators import with_role from bot.pagination import LinePaginator from bot.utils import time from .watchchannel import WatchChannel @@ -32,13 +31,13 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): ) @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True) - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def nomination_group(self, ctx: Context) -> None: """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" await ctx.send_help(ctx.command) @nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",)) - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def watched_command( self, ctx: Context, oldest_first: bool = False, update_cache: bool = True ) -> None: @@ -53,7 +52,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) @nomination_group.command(name='oldest') - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: """ Shows talent pool monitored users ordered by oldest nomination. @@ -64,7 +63,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) @nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",)) - @with_role(*STAFF_ROLES) + @has_any_role(*STAFF_ROLES) async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """ Relay messages sent by the given `user` to the `#talent-pool` channel. @@ -129,7 +128,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): await ctx.send(msg) @nomination_group.command(name='history', aliases=('info', 'search')) - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def history_command(self, ctx: Context, user: FetchedMember) -> None: """Shows the specified user's nomination history.""" result = await self.bot.api_client.get( @@ -158,7 +157,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): ) @nomination_group.command(name='unwatch', aliases=('end', ), root_aliases=("unnominate",)) - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """ Ends the active nomination of the specified user with the given reason. @@ -171,13 +170,13 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): await ctx.send(":x: The specified user does not have an active nomination") @nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True) - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def nomination_edit_group(self, ctx: Context) -> None: """Commands to edit nominations.""" await ctx.send_help(ctx.command) @nomination_edit_group.command(name='reason') - @with_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def edit_reason_command(self, ctx: Context, nomination_id: int, *, reason: str) -> None: """ Edits the reason/unnominate reason for the nomination with the given `id` depending on the status. diff --git a/bot/decorators.py b/bot/decorators.py index 500197c89..bdb224039 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -12,7 +12,7 @@ from discord.ext import commands from discord.ext.commands import Cog, Context from bot.constants import Channels, ERROR_REPLIES, RedirectOutput -from bot.utils.checks import in_whitelist_check, with_role_check, without_role_check +from bot.utils.checks import in_whitelist_check, without_role_check log = logging.getLogger(__name__) @@ -45,14 +45,6 @@ def in_whitelist( return commands.check(predicate) -def with_role(*role_ids: int) -> Callable: - """Returns True if the user has any one of the roles in role_ids.""" - async def predicate(ctx: Context) -> bool: - """With role checker predicate.""" - return with_role_check(ctx, *role_ids) - return commands.check(predicate) - - def without_role(*role_ids: int) -> Callable: """Returns True if the user does not have any of the roles in role_ids.""" async def predicate(ctx: Context) -> bool: -- cgit v1.2.3 From 24657d4fc84208b4ab334db5c2a2c91cb2449219 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 5 Sep 2020 19:53:08 -0700 Subject: Implement the without_role decorator by negating has_any_role --- bot/decorators.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index bdb224039..56028ad8a 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -6,13 +6,12 @@ from functools import wraps from typing import Callable, Container, Optional, Union from weakref import WeakValueDictionary -from discord import Colour, Embed, Member -from discord.errors import NotFound +from discord import Colour, Embed, Member, NotFound from discord.ext import commands from discord.ext.commands import Cog, Context from bot.constants import Channels, ERROR_REPLIES, RedirectOutput -from bot.utils.checks import in_whitelist_check, without_role_check +from bot.utils.checks import in_whitelist_check log = logging.getLogger(__name__) @@ -45,10 +44,22 @@ def in_whitelist( return commands.check(predicate) -def without_role(*role_ids: int) -> Callable: - """Returns True if the user does not have any of the roles in role_ids.""" +def without_role(*roles: Union[str, int]) -> Callable: + """ + Returns True if the user does not have any of the roles specified. + + `roles` are the names or IDs of the disallowed roles. + """ async def predicate(ctx: Context) -> bool: - return without_role_check(ctx, *role_ids) + try: + await commands.has_any_role(*roles).predicate(ctx) + except commands.MissingAnyRole: + return True + else: + # This error is never shown to users, so don't bother trying to make it too pretty. + roles_ = ", ".join(f"'{item}'" for item in roles) + raise commands.CheckFailure(f"You have at least one of the disallowed roles: {roles_}") + return commands.check(predicate) -- cgit v1.2.3 From b0a2ebb87986856240b5e20967424d035998ac77 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 5 Sep 2020 20:08:59 -0700 Subject: Use has_any_role's predicate directly In some places, it's more appropriate than using with_role_check since it will raise CheckFailures. This applies to `cog_check`s and other things which effectively act as command checks. --- bot/cogs/dm_relay.py | 6 +++--- bot/cogs/extensions.py | 5 ++--- bot/cogs/filter_lists.py | 7 +++---- bot/cogs/help_channels.py | 7 +++---- bot/cogs/moderation/infractions.py | 5 ++--- bot/cogs/moderation/management.py | 6 +++--- bot/cogs/moderation/silence.py | 5 ++--- bot/cogs/moderation/slowmode.py | 7 +++---- bot/cogs/moderation/superstarify.py | 7 +++---- 9 files changed, 24 insertions(+), 31 deletions(-) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index 0d8f340b4..7a3fe49bb 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -10,7 +10,7 @@ from bot import constants from bot.bot import Bot from bot.converters import UserMentionOrID from bot.utils import RedisCache -from bot.utils.checks import in_whitelist_check, with_role_check +from bot.utils.checks import in_whitelist_check from bot.utils.messages import send_attachments from bot.utils.webhooks import send_webhook @@ -105,10 +105,10 @@ class DMRelay(Cog): except discord.HTTPException: log.exception("Failed to send an attachment to the webhook") - def cog_check(self, ctx: commands.Context) -> bool: + async def cog_check(self, ctx: commands.Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" checks = [ - with_role_check(ctx, *constants.MODERATION_ROLES), + await commands.has_any_role(*constants.MODERATION_ROLES).predicate(ctx), in_whitelist_check( ctx, channels=[constants.Channels.dm_log], diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index 396e406b0..5977e6f3c 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -11,7 +11,6 @@ from discord.ext.commands import Context, group from bot.bot import Bot from bot.constants import Emojis, MODERATION_ROLES, Roles, URLs from bot.pagination import LinePaginator -from bot.utils.checks import with_role_check log = logging.getLogger(__name__) @@ -219,9 +218,9 @@ class Extensions(commands.Cog): return msg, error_msg # This cannot be static (must have a __func__ attribute). - def cog_check(self, ctx: Context) -> bool: + async def cog_check(self, ctx: Context) -> bool: """Only allow moderators and core developers to invoke the commands in this cog.""" - return with_role_check(ctx, *MODERATION_ROLES, Roles.core_developers) + return await commands.has_any_role(*MODERATION_ROLES, Roles.core_developers).predicate(ctx) # This cannot be static (must have a __func__ attribute). async def cog_command_error(self, ctx: Context, error: Exception) -> None: diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py index c15adc461..232c1e48b 100644 --- a/bot/cogs/filter_lists.py +++ b/bot/cogs/filter_lists.py @@ -2,14 +2,13 @@ import logging from typing import Optional from discord import Colour, Embed -from discord.ext.commands import BadArgument, Cog, Context, IDConverter, group +from discord.ext.commands import BadArgument, Cog, Context, IDConverter, group, has_any_role from bot import constants from bot.api import ResponseCodeError from bot.bot import Bot from bot.converters import ValidDiscordServerInvite, ValidFilterListType from bot.pagination import LinePaginator -from bot.utils.checks import with_role_check log = logging.getLogger(__name__) @@ -263,9 +262,9 @@ class FilterLists(Cog): """Syncs both allowlists and denylists with the API.""" await self._sync_data(ctx) - def cog_check(self, ctx: Context) -> bool: + async def cog_check(self, ctx: Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" - return with_role_check(ctx, *constants.MODERATION_ROLES) + return await has_any_role(*constants.MODERATION_ROLES).predicate(ctx) def setup(bot: Bot) -> None: diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 0f9cac89e..17142071f 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -14,7 +14,6 @@ from discord.ext import commands from bot import constants from bot.bot import Bot from bot.utils import RedisCache -from bot.utils.checks import with_role_check from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) @@ -196,12 +195,12 @@ class HelpChannels(commands.Cog): return True log.trace(f"{ctx.author} is not the help channel claimant, checking roles.") - role_check = with_role_check(ctx, *constants.HelpChannels.cmd_whitelist) + has_role = await commands.has_any_role(*constants.HelpChannels.cmd_whitelist).predicate(ctx) - if role_check: + if has_role: self.bot.stats.incr("help.dormant_invoke.staff") - return role_check + return has_role @commands.command(name="close", aliases=["dormant", "solved"], enabled=False) async def close_command(self, ctx: commands.Context) -> None: diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 8df642428..8f0def2bc 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -12,7 +12,6 @@ from bot.bot import Bot from bot.constants import Event from bot.converters import Expiry, FetchedMember from bot.decorators import respect_role_hierarchy -from bot.utils.checks import with_role_check from . import utils from .scheduler import InfractionScheduler from .utils import UserSnowflake @@ -357,9 +356,9 @@ class Infractions(InfractionScheduler, commands.Cog): # endregion # This cannot be static (must have a __func__ attribute). - def cog_check(self, ctx: Context) -> bool: + async def cog_check(self, ctx: Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" - return with_role_check(ctx, *constants.MODERATION_ROLES) + return await commands.has_any_role(*constants.MODERATION_ROLES).predicate(ctx) # This cannot be static (must have a __func__ attribute). async def cog_command_error(self, ctx: Context, error: Exception) -> None: diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 672bb0e9c..83342ac90 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -12,7 +12,7 @@ from bot.bot import Bot from bot.converters import Expiry, InfractionSearchQuery, allowed_strings, proxy_user from bot.pagination import LinePaginator from bot.utils import time -from bot.utils.checks import in_whitelist_check, with_role_check +from bot.utils.checks import in_whitelist_check from . import utils from .infractions import Infractions from .modlog import ModLog @@ -282,10 +282,10 @@ class ModManagement(commands.Cog): # endregion # This cannot be static (must have a __func__ attribute). - def cog_check(self, ctx: Context) -> bool: + async def cog_check(self, ctx: Context) -> bool: """Only allow moderators inside moderator channels to invoke the commands in this cog.""" checks = [ - with_role_check(ctx, *constants.MODERATION_ROLES), + await commands.has_any_role(*constants.MODERATION_ROLES).predicate(ctx), in_whitelist_check( ctx, channels=constants.MODERATION_CHANNELS, diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index f8a6592bc..ecc9f8d22 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -10,7 +10,6 @@ from discord.ext.commands import Context from bot.bot import Bot from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles from bot.converters import HushDurationConverter -from bot.utils.checks import with_role_check from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) @@ -160,6 +159,6 @@ class Silence(commands.Cog): asyncio.create_task(self._mod_alerts_channel.send(message)) # This cannot be static (must have a __func__ attribute). - def cog_check(self, ctx: Context) -> bool: + async def cog_check(self, ctx: Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" - return with_role_check(ctx, *MODERATION_ROLES) + return await commands.has_any_role(*MODERATION_ROLES).predicate(ctx) diff --git a/bot/cogs/moderation/slowmode.py b/bot/cogs/moderation/slowmode.py index 1d055afac..efd862aa5 100644 --- a/bot/cogs/moderation/slowmode.py +++ b/bot/cogs/moderation/slowmode.py @@ -4,12 +4,11 @@ from typing import Optional from dateutil.relativedelta import relativedelta from discord import TextChannel -from discord.ext.commands import Cog, Context, group +from discord.ext.commands import Cog, Context, group, has_any_role from bot.bot import Bot from bot.constants import Emojis, MODERATION_ROLES from bot.converters import DurationDelta -from bot.decorators import with_role_check from bot.utils import time log = logging.getLogger(__name__) @@ -87,9 +86,9 @@ class Slowmode(Cog): f'{Emojis.check_mark} The slowmode delay for {channel.mention} has been reset to 0 seconds.' ) - def cog_check(self, ctx: Context) -> bool: + async def cog_check(self, ctx: Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" - return with_role_check(ctx, *MODERATION_ROLES) + return await has_any_role(*MODERATION_ROLES).predicate(ctx) def setup(bot: Bot) -> None: diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 867de815a..081c2d0b9 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -6,12 +6,11 @@ import typing as t from pathlib import Path from discord import Colour, Embed, Member -from discord.ext.commands import Cog, Context, command +from discord.ext.commands import Cog, Context, command, has_any_role from bot import constants from bot.bot import Bot from bot.converters import Expiry -from bot.utils.checks import with_role_check from bot.utils.time import format_infraction from . import utils from .scheduler import InfractionScheduler @@ -234,6 +233,6 @@ class Superstarify(InfractionScheduler, Cog): return rng.choice(STAR_NAMES) # This cannot be static (must have a __func__ attribute). - def cog_check(self, ctx: Context) -> bool: + async def cog_check(self, ctx: Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" - return with_role_check(ctx, *constants.MODERATION_ROLES) + return await has_any_role(*constants.MODERATION_ROLES).predicate(ctx) -- cgit v1.2.3 From 0c6215f3f6e7247a5e13199931f733a8e203047e Mon Sep 17 00:00:00 2001 From: kosayoda Date: Mon, 7 Sep 2020 21:08:54 +0800 Subject: Remove everyone_ping rule from antispam. The feature will be moved to the filtering cog. --- bot/cogs/antispam.py | 1 - bot/rules/__init__.py | 1 - bot/rules/everyone_ping.py | 44 --------------- config-default.yml | 4 -- tests/bot/rules/test_everyone_ping.py | 102 ---------------------------------- 5 files changed, 152 deletions(-) delete mode 100644 bot/rules/everyone_ping.py delete mode 100644 tests/bot/rules/test_everyone_ping.py diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index b8939113f..5c97621fb 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -36,7 +36,6 @@ RULE_FUNCTION_MAPPING = { 'mentions': rules.apply_mentions, 'newlines': rules.apply_newlines, 'role_mentions': rules.apply_role_mentions, - 'everyone_ping': rules.apply_everyone_ping, } diff --git a/bot/rules/__init__.py b/bot/rules/__init__.py index 8a69cadee..a01ceae73 100644 --- a/bot/rules/__init__.py +++ b/bot/rules/__init__.py @@ -10,4 +10,3 @@ from .links import apply as apply_links from .mentions import apply as apply_mentions from .newlines import apply as apply_newlines from .role_mentions import apply as apply_role_mentions -from .everyone_ping import apply as apply_everyone_ping diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py deleted file mode 100644 index 8fc03b924..000000000 --- a/bot/rules/everyone_ping.py +++ /dev/null @@ -1,44 +0,0 @@ -import random -import re -from typing import Dict, Iterable, List, Optional, Tuple - -from discord import Embed, Member, Message - -from bot.constants import Colours, Guild, NEGATIVE_REPLIES - -# Generate regex for checking for pings: -guild_id = Guild.id -EVERYONE_PING_RE = re.compile(rf"@everyone|<@&{guild_id}>") -CODE_BLOCK_RE = re.compile( - r"(?P``?)[^`]+?(?P=delim)(?!`+)" # Inline codeblock - r"|```(.+?)```", # Multiline codeblock - re.DOTALL | re.MULTILINE -) - - -async def apply( - last_message: Message, - recent_messages: List[Message], - config: Dict[str, int], -) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - """Detects if a user has sent an '@everyone' ping.""" - relevant_messages = tuple(msg for msg in recent_messages if msg.author == last_message.author) - - everyone_messages_count = 0 - for msg in relevant_messages: - content = CODE_BLOCK_RE.sub("", msg.content) # Remove codeblocks in the message - if matches := len(EVERYONE_PING_RE.findall(content)): - everyone_messages_count += matches - - if everyone_messages_count > config["max"]: - # Send the channel an embed giving the user more info: - embed_text = f"Please don't try to ping {last_message.guild.member_count:,} people." - embed = Embed(title=random.choice(NEGATIVE_REPLIES), description=embed_text, colour=Colours.soft_red) - await last_message.channel.send(embed=embed) - - return ( - "pinged the everyone role", - (last_message.author,), - relevant_messages, - ) - return None diff --git a/config-default.yml b/config-default.yml index e9324c62f..c1eef713f 100644 --- a/config-default.yml +++ b/config-default.yml @@ -389,10 +389,6 @@ anti_spam: interval: 10 max: 3 - everyone_ping: - interval: 10 - max: 0 - reddit: subreddits: diff --git a/tests/bot/rules/test_everyone_ping.py b/tests/bot/rules/test_everyone_ping.py deleted file mode 100644 index 3ecc43cdc..000000000 --- a/tests/bot/rules/test_everyone_ping.py +++ /dev/null @@ -1,102 +0,0 @@ -from typing import Iterable - -from bot.rules import everyone_ping -from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockGuild, MockMessage - -NUM_GUILD_MEMBERS = 100 - - -def make_msg(author: str, message: str) -> MockMessage: - """Build a message with `message` as the content sent.""" - mocked_guild = MockGuild(member_count=NUM_GUILD_MEMBERS) - return MockMessage(author=author, content=message, guild=mocked_guild) - - -class EveryonePingRuleTest(RuleTest): - """Tests the `everyone_ping` antispam rule.""" - - def setUp(self): - self.apply = everyone_ping.apply - self.config = { - "max": 0, # Max allowed @everyone pings per user - "interval": 10, - } - - async def test_disallows_everyone_ping(self): - """Cases with an @everyone ping.""" - cases = ( - DisallowedCase( - [make_msg("bob", "@everyone")], - ("bob",), - 1 - ), - DisallowedCase( - [make_msg("bob", "Let me ping @everyone in the server.")], - ("bob",), - 1 - ), - DisallowedCase( - [make_msg("bob", "`codeblock message` and @everyone ping")], - ("bob",), - 1 - ), - DisallowedCase( - [make_msg("bob", "`sandwich` @everyone `ping between codeblocks`.")], - ("bob",), - 1 - ), - DisallowedCase( - [make_msg("bob", "This is a multiline\n@everyone\nping.")], - ("bob",), - 1 - ), - # Not actually valid code blocks - DisallowedCase( - [make_msg("bob", "`@everyone``")], - ("bob",), - 1 - ), - DisallowedCase( - [make_msg("bob", "`@everyone``````")], - ("bob",), - 1 - ), - DisallowedCase( - [make_msg("bob", "``@everyone``````")], - ("bob",), - 1 - ), - ) - - await self.run_disallowed(cases) - - async def test_allows_inline_codeblock_everyone_ping(self): - """Cases with an @everyone ping in an inline codeblock.""" - cases = ( - [make_msg("bob", "Codeblock has `@everyone` ping.")], - [make_msg("bob", "Multiple `codeblocks` including `@everyone` ping.")], - [make_msg("bob", "This is a valid ``inline @everyone` ping.")], - ) - - await self.run_allowed(cases) - - async def test_allows_multiline_codeblock_everyone_ping(self): - """Cases with an @everyone ping in a multiline codeblock.""" - cases = ( - [make_msg("bob", "```Multiline codeblock has\nan `@everyone` ping.```")], - [make_msg("bob", "``` `@everyone``` ` `")], - ) - - await self.run_allowed(cases) - - def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: - last_message = case.recent_messages[0] - return tuple( - msg - for msg in case.recent_messages - if msg.author == last_message.author - ) - - def get_report(self, case: DisallowedCase) -> str: - return "pinged the everyone role" -- cgit v1.2.3 From 8e161a3ef7921c794e128df45302fb3a9e28bd2b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 6 Sep 2020 09:07:59 -0700 Subject: Implement role checks using has_any_role Use `has_any_role` to reduce redundancy. Because discord.py always makes a check's predicate a coroutine, the checks now have to be awaited. --- bot/cogs/information.py | 7 ++++--- bot/cogs/reminders.py | 8 ++++---- bot/cogs/verification.py | 5 +++-- bot/utils/checks.py | 49 ++++++++++++++++++++++++------------------------ 4 files changed, 35 insertions(+), 34 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index abfbcb84e..5b132a469 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -15,11 +15,12 @@ from bot import constants from bot.bot import Bot from bot.decorators import in_whitelist from bot.pagination import LinePaginator -from bot.utils.checks import InWhitelistCheckFailure, cooldown_with_role_bypass, with_role_check +from bot.utils.checks import InWhitelistCheckFailure, cooldown_with_role_bypass, without_role_check from bot.utils.time import time_since log = logging.getLogger(__name__) + STATUS_EMOTES = { Status.offline: constants.Emojis.status_offline, Status.dnd: constants.Emojis.status_dnd, @@ -197,12 +198,12 @@ class Information(Cog): user = ctx.author # Do a role check if this is being executed on someone other than the caller - elif user != ctx.author and not with_role_check(ctx, *constants.MODERATION_ROLES): + elif user != ctx.author and await without_role_check(ctx, *constants.MODERATION_ROLES): await ctx.send("You may not use this command on users other than yourself.") return # Non-staff may only do this in #bot-commands - if not with_role_check(ctx, *constants.STAFF_ROLES): + if await without_role_check(ctx, *constants.STAFF_ROLES): if not ctx.channel.id == constants.Channels.bot_commands: raise InWhitelistCheckFailure(constants.Channels.bot_commands) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 08bce2153..d7357e3fa 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -117,9 +117,9 @@ class Reminders(Cog): If mentions aren't allowed, also return the type of mention(s) disallowed. """ - if without_role_check(ctx, *STAFF_ROLES): + if await without_role_check(ctx, *STAFF_ROLES): return False, "members/roles" - elif without_role_check(ctx, *MODERATION_ROLES): + elif await without_role_check(ctx, *MODERATION_ROLES): return all(isinstance(mention, discord.Member) for mention in mentions), "roles" else: return True, "" @@ -240,7 +240,7 @@ class Reminders(Cog): Expiration is parsed per: http://strftime.org/ """ # If the user is not staff, we need to verify whether or not to make a reminder at all. - if without_role_check(ctx, *STAFF_ROLES): + if await without_role_check(ctx, *STAFF_ROLES): # If they don't have permission to set a reminder in this channel if ctx.channel.id not in WHITELISTED_CHANNELS: @@ -431,7 +431,7 @@ class Reminders(Cog): The check passes when the user is an admin, or if they created the reminder. """ - if with_role_check(ctx, Roles.admins): + if await with_role_check(ctx, Roles.admins): return True api_response = await self.bot.api_client.get(f"bot/reminders/{reminder_id}") diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index ae156cf70..afbe1d3b8 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -178,9 +178,10 @@ class Verification(Cog): error.handled = True @staticmethod - def bot_check(ctx: Context) -> bool: + async def bot_check(ctx: Context) -> bool: """Block any command within the verification channel that is not !accept.""" - if ctx.channel.id == constants.Channels.verification and without_role_check(ctx, *constants.MODERATION_ROLES): + is_verification = ctx.channel.id == constants.Channels.verification + if is_verification and await without_role_check(ctx, *constants.MODERATION_ROLES): return ctx.command.name == "accept" else: return True diff --git a/bot/utils/checks.py b/bot/utils/checks.py index f0ef36302..c2e41efb3 100644 --- a/bot/utils/checks.py +++ b/bot/utils/checks.py @@ -1,6 +1,6 @@ import datetime import logging -from typing import Callable, Container, Iterable, Optional +from typing import Callable, Container, Iterable, Optional, Union from discord.ext.commands import ( BucketType, @@ -11,6 +11,8 @@ from discord.ext.commands import ( Context, Cooldown, CooldownMapping, + NoPrivateMessage, + has_any_role, ) from bot import constants @@ -89,35 +91,32 @@ def in_whitelist_check( return False -def with_role_check(ctx: Context, *role_ids: int) -> bool: - """Returns True if the user has any one of the roles in role_ids.""" - if not ctx.guild: # Return False in a DM - log.trace(f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. " - "This command is restricted by the with_role decorator. Rejecting request.") - return False +async def with_role_check(ctx: Context, *roles: Union[str, int]) -> bool: + """ + Returns True if the context's author has any of the specified roles. - for role in ctx.author.roles: - if role.id in role_ids: - log.trace(f"{ctx.author} has the '{role.name}' role, and passes the check.") - return True + `roles` are the names or IDs of the roles for which to check. + False is always returns if the context is outside a guild. + """ + try: + return await has_any_role(*roles).predicate(ctx) + except CheckFailure: + return False - log.trace(f"{ctx.author} does not have the required role to use " - f"the '{ctx.command.name}' command, so the request is rejected.") - return False +async def without_role_check(ctx: Context, *roles: Union[str, int]) -> bool: + """ + Returns True if the context's author doesn't have any of the specified roles. -def without_role_check(ctx: Context, *role_ids: int) -> bool: - """Returns True if the user does not have any of the roles in role_ids.""" - if not ctx.guild: # Return False in a DM - log.trace(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. " - "This command is restricted by the without_role decorator. Rejecting request.") + `roles` are the names or IDs of the roles for which to check. + False is always returns if the context is outside a guild. + """ + try: + return not await has_any_role(*roles).predicate(ctx) + except NoPrivateMessage: return False - - author_roles = [role.id for role in ctx.author.roles] - check = all(role not in author_roles for role in role_ids) - log.trace(f"{ctx.author} tried to call the '{ctx.command.name}' command. " - f"The result of the without_role check was {check}.") - return check + except CheckFailure: + return True def cooldown_with_role_bypass(rate: int, per: float, type: BucketType = BucketType.default, *, -- cgit v1.2.3 From 7b311196613e358557a14aa75f01e8a54ab3e698 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 7 Sep 2020 15:49:31 -0700 Subject: Sync: remove confirmation The confirmation was intended to be a safe guard against cache issues that would cause a huge number of roles/users to deleted after syncing. With `wait_until_guild_available`, such cache issue shouldn't arise. Therefore, this feature is obsolete. Resolve #1075 --- bot/cogs/sync/syncers.py | 170 +------------------ bot/constants.py | 7 - config-default.yml | 4 - tests/bot/cogs/sync/test_base.py | 357 ++------------------------------------- 4 files changed, 20 insertions(+), 518 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index f7ba811bc..b3819a1e1 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -1,15 +1,11 @@ import abc -import asyncio import logging import typing as t from collections import namedtuple -from functools import partial -import discord -from discord import Guild, HTTPException, Member, Message, Reaction, User +from discord import Guild from discord.ext.commands import Context -from bot import constants from bot.api import ResponseCodeError from bot.bot import Bot @@ -25,9 +21,6 @@ _Diff = namedtuple('Diff', ('created', 'updated', 'deleted')) class Syncer(abc.ABC): """Base class for synchronising the database with objects in the Discord cache.""" - _CORE_DEV_MENTION = f"<@&{constants.Roles.core_developers}> " - _REACTION_EMOJIS = (constants.Emojis.check_mark, constants.Emojis.cross_mark) - def __init__(self, bot: Bot) -> None: self.bot = bot @@ -37,112 +30,6 @@ class Syncer(abc.ABC): """The name of the syncer; used in output messages and logging.""" raise NotImplementedError # pragma: no cover - async def _send_prompt(self, message: t.Optional[Message] = None) -> t.Optional[Message]: - """ - Send a prompt to confirm or abort a sync using reactions and return the sent message. - - If a message is given, it is edited to display the prompt and reactions. Otherwise, a new - message is sent to the dev-core channel and mentions the core developers role. If the - channel cannot be retrieved, return None. - """ - log.trace(f"Sending {self.name} sync confirmation prompt.") - - msg_content = ( - f'Possible cache issue while syncing {self.name}s. ' - f'More than {constants.Sync.max_diff} {self.name}s were changed. ' - f'React to confirm or abort the sync.' - ) - - # Send to core developers if it's an automatic sync. - if not message: - log.trace("Message not provided for confirmation; creating a new one in dev-core.") - channel = self.bot.get_channel(constants.Channels.dev_core) - - if not channel: - log.debug("Failed to get the dev-core channel from cache; attempting to fetch it.") - try: - channel = await self.bot.fetch_channel(constants.Channels.dev_core) - except HTTPException: - log.exception( - f"Failed to fetch channel for sending sync confirmation prompt; " - f"aborting {self.name} sync." - ) - return None - - allowed_roles = [discord.Object(constants.Roles.core_developers)] - message = await channel.send( - f"{self._CORE_DEV_MENTION}{msg_content}", - allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) - ) - else: - await message.edit(content=msg_content) - - # Add the initial reactions. - log.trace(f"Adding reactions to {self.name} syncer confirmation prompt.") - for emoji in self._REACTION_EMOJIS: - await message.add_reaction(emoji) - - return message - - def _reaction_check( - self, - author: Member, - message: Message, - reaction: Reaction, - user: t.Union[Member, User] - ) -> bool: - """ - Return True if the `reaction` is a valid confirmation or abort reaction on `message`. - - If the `author` of the prompt is a bot, then a reaction by any core developer will be - considered valid. Otherwise, the author of the reaction (`user`) will have to be the - `author` of the prompt. - """ - # For automatic syncs, check for the core dev role instead of an exact author - has_role = any(constants.Roles.core_developers == role.id for role in user.roles) - return ( - reaction.message.id == message.id - and not user.bot - and (has_role if author.bot else user == author) - and str(reaction.emoji) in self._REACTION_EMOJIS - ) - - async def _wait_for_confirmation(self, author: Member, message: Message) -> bool: - """ - Wait for a confirmation reaction by `author` on `message` and return True if confirmed. - - Uses the `_reaction_check` function to determine if a reaction is valid. - - If there is no reaction within `bot.constants.Sync.confirm_timeout` seconds, return False. - To acknowledge the reaction (or lack thereof), `message` will be edited. - """ - # Preserve the core-dev role mention in the message edits so users aren't confused about - # where notifications came from. - mention = self._CORE_DEV_MENTION if author.bot else "" - - reaction = None - try: - log.trace(f"Waiting for a reaction to the {self.name} syncer confirmation prompt.") - reaction, _ = await self.bot.wait_for( - 'reaction_add', - check=partial(self._reaction_check, author, message), - timeout=constants.Sync.confirm_timeout - ) - except asyncio.TimeoutError: - # reaction will remain none thus sync will be aborted in the finally block below. - log.debug(f"The {self.name} syncer confirmation prompt timed out.") - - if str(reaction) == constants.Emojis.check_mark: - log.trace(f"The {self.name} syncer was confirmed.") - await message.edit(content=f':ok_hand: {mention}{self.name} sync will proceed.') - return True - else: - log.info(f"The {self.name} syncer was aborted or timed out!") - await message.edit( - content=f':warning: {mention}{self.name} sync aborted or timed out!' - ) - return False - @abc.abstractmethod async def _get_diff(self, guild: Guild) -> _Diff: """Return the difference between the cache of `guild` and the database.""" @@ -153,34 +40,6 @@ class Syncer(abc.ABC): """Perform the API calls for synchronisation.""" raise NotImplementedError # pragma: no cover - async def _get_confirmation_result( - self, - diff_size: int, - author: Member, - message: t.Optional[Message] = None - ) -> t.Tuple[bool, t.Optional[Message]]: - """ - Prompt for confirmation and return a tuple of the result and the prompt message. - - `diff_size` is the size of the diff of the sync. If it is greater than - `bot.constants.Sync.max_diff`, the prompt will be sent. The `author` is the invoked of the - sync and the `message` is an extant message to edit to display the prompt. - - If confirmed or no confirmation was needed, the result is True. The returned message will - either be the given `message` or a new one which was created when sending the prompt. - """ - log.trace(f"Determining if confirmation prompt should be sent for {self.name} syncer.") - if diff_size > constants.Sync.max_diff: - message = await self._send_prompt(message) - if not message: - return False, None # Couldn't get channel. - - confirmed = await self._wait_for_confirmation(author, message) - if not confirmed: - return False, message # Sync aborted. - - return True, message - async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> None: """ Synchronise the database with the cache of `guild`. @@ -191,24 +50,8 @@ class Syncer(abc.ABC): """ log.info(f"Starting {self.name} syncer.") - message = None - author = self.bot.user - if ctx: - message = await ctx.send(f"📊 Synchronising {self.name}s.") - author = ctx.author - + message = await ctx.send(f"📊 Synchronising {self.name}s.") if ctx else None diff = await self._get_diff(guild) - diff_dict = diff._asdict() # Ugly method for transforming the NamedTuple into a dict - totals = {k: len(v) for k, v in diff_dict.items() if v is not None} - diff_size = sum(totals.values()) - - confirmed, message = await self._get_confirmation_result(diff_size, author, message) - if not confirmed: - return - - # Preserve the core-dev role mention in the message edits so users aren't confused about - # where notifications came from. - mention = self._CORE_DEV_MENTION if author.bot else "" try: await self._sync(diff) @@ -217,11 +60,14 @@ class Syncer(abc.ABC): # Don't show response text because it's probably some really long HTML. results = f"status {e.status}\n```{e.response_json or 'See log output for details'}```" - content = f":x: {mention}Synchronisation of {self.name}s failed: {results}" + content = f":x: Synchronisation of {self.name}s failed: {results}" else: - results = ", ".join(f"{name} `{total}`" for name, total in totals.items()) + diff_dict = diff._asdict() # Ugly method for transforming the NamedTuple into a dict + results = (f"{name} `{len(val)}`" for name, val in diff_dict.items() if val is not None) + results = ", ".join(results) + log.info(f"{self.name} syncer finished: {results}.") - content = f":ok_hand: {mention}Synchronisation of {self.name}s complete: {results}" + content = f":ok_hand: Synchronisation of {self.name}s complete: {results}" if message: await message.edit(content=content) diff --git a/bot/constants.py b/bot/constants.py index 17fe34e95..3129354d3 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -563,13 +563,6 @@ class RedirectOutput(metaclass=YAMLGetter): delete_delay: int -class Sync(metaclass=YAMLGetter): - section = 'sync' - - confirm_timeout: int - max_diff: int - - class PythonNews(metaclass=YAMLGetter): section = 'python_news' diff --git a/config-default.yml b/config-default.yml index 6e7cff92d..d48739002 100644 --- a/config-default.yml +++ b/config-default.yml @@ -460,10 +460,6 @@ redirect_output: delete_invocation: true delete_delay: 15 -sync: - confirm_timeout: 300 - max_diff: 10 - duck_pond: threshold: 5 custom_emojis: diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 70aea2bab..c3456f724 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -1,12 +1,9 @@ -import asyncio import unittest from unittest import mock -import discord -from bot import constants from bot.api import ResponseCodeError -from bot.cogs.sync.syncers import Syncer, _Diff +from bot.cogs.sync.syncers import Syncer from tests import helpers @@ -30,280 +27,16 @@ class SyncerBaseTests(unittest.TestCase): Syncer(self.bot) -class SyncerSendPromptTests(unittest.IsolatedAsyncioTestCase): - """Tests for sending the sync confirmation prompt.""" - - def setUp(self): - self.bot = helpers.MockBot() - self.syncer = TestSyncer(self.bot) - - def mock_get_channel(self): - """Fixture to return a mock channel and message for when `get_channel` is used.""" - self.bot.reset_mock() - - mock_channel = helpers.MockTextChannel() - mock_message = helpers.MockMessage() - - mock_channel.send.return_value = mock_message - self.bot.get_channel.return_value = mock_channel - - return mock_channel, mock_message - - def mock_fetch_channel(self): - """Fixture to return a mock channel and message for when `fetch_channel` is used.""" - self.bot.reset_mock() - - mock_channel = helpers.MockTextChannel() - mock_message = helpers.MockMessage() - - self.bot.get_channel.return_value = None - mock_channel.send.return_value = mock_message - self.bot.fetch_channel.return_value = mock_channel - - return mock_channel, mock_message - - async def test_send_prompt_edits_and_returns_message(self): - """The given message should be edited to display the prompt and then should be returned.""" - msg = helpers.MockMessage() - ret_val = await self.syncer._send_prompt(msg) - - msg.edit.assert_called_once() - self.assertIn("content", msg.edit.call_args[1]) - self.assertEqual(ret_val, msg) - - async def test_send_prompt_gets_dev_core_channel(self): - """The dev-core channel should be retrieved if an extant message isn't given.""" - subtests = ( - (self.bot.get_channel, self.mock_get_channel), - (self.bot.fetch_channel, self.mock_fetch_channel), - ) - - for method, mock_ in subtests: - with self.subTest(method=method, msg=mock_.__name__): - mock_() - await self.syncer._send_prompt() - - method.assert_called_once_with(constants.Channels.dev_core) - - async def test_send_prompt_returns_none_if_channel_fetch_fails(self): - """None should be returned if there's an HTTPException when fetching the channel.""" - self.bot.get_channel.return_value = None - self.bot.fetch_channel.side_effect = discord.HTTPException(mock.MagicMock(), "test error!") - - ret_val = await self.syncer._send_prompt() - - self.assertIsNone(ret_val) - - async def test_send_prompt_sends_and_returns_new_message_if_not_given(self): - """A new message mentioning core devs should be sent and returned if message isn't given.""" - for mock_ in (self.mock_get_channel, self.mock_fetch_channel): - with self.subTest(msg=mock_.__name__): - mock_channel, mock_message = mock_() - ret_val = await self.syncer._send_prompt() - - mock_channel.send.assert_called_once() - self.assertIn(self.syncer._CORE_DEV_MENTION, mock_channel.send.call_args[0][0]) - self.assertEqual(ret_val, mock_message) - - async def test_send_prompt_adds_reactions(self): - """The message should have reactions for confirmation added.""" - extant_message = helpers.MockMessage() - subtests = ( - (extant_message, lambda: (None, extant_message)), - (None, self.mock_get_channel), - (None, self.mock_fetch_channel), - ) - - for message_arg, mock_ in subtests: - subtest_msg = "Extant message" if mock_.__name__ == "" else mock_.__name__ - - with self.subTest(msg=subtest_msg): - _, mock_message = mock_() - await self.syncer._send_prompt(message_arg) - - calls = [mock.call(emoji) for emoji in self.syncer._REACTION_EMOJIS] - mock_message.add_reaction.assert_has_calls(calls) - - -class SyncerConfirmationTests(unittest.IsolatedAsyncioTestCase): - """Tests for waiting for a sync confirmation reaction on the prompt.""" - - def setUp(self): - self.bot = helpers.MockBot() - self.syncer = TestSyncer(self.bot) - self.core_dev_role = helpers.MockRole(id=constants.Roles.core_developers) - - @staticmethod - def get_message_reaction(emoji): - """Fixture to return a mock message an reaction from the given `emoji`.""" - message = helpers.MockMessage() - reaction = helpers.MockReaction(emoji=emoji, message=message) - - return message, reaction - - def test_reaction_check_for_valid_emoji_and_authors(self): - """Should return True if authors are identical or are a bot and a core dev, respectively.""" - user_subtests = ( - ( - helpers.MockMember(id=77), - helpers.MockMember(id=77), - "identical users", - ), - ( - helpers.MockMember(id=77, bot=True), - helpers.MockMember(id=43, roles=[self.core_dev_role]), - "bot author and core-dev reactor", - ), - ) - - for emoji in self.syncer._REACTION_EMOJIS: - for author, user, msg in user_subtests: - with self.subTest(author=author, user=user, emoji=emoji, msg=msg): - message, reaction = self.get_message_reaction(emoji) - ret_val = self.syncer._reaction_check(author, message, reaction, user) - - self.assertTrue(ret_val) - - def test_reaction_check_for_invalid_reactions(self): - """Should return False for invalid reaction events.""" - valid_emoji = self.syncer._REACTION_EMOJIS[0] - subtests = ( - ( - helpers.MockMember(id=77), - *self.get_message_reaction(valid_emoji), - helpers.MockMember(id=43, roles=[self.core_dev_role]), - "users are not identical", - ), - ( - helpers.MockMember(id=77, bot=True), - *self.get_message_reaction(valid_emoji), - helpers.MockMember(id=43), - "reactor lacks the core-dev role", - ), - ( - helpers.MockMember(id=77, bot=True, roles=[self.core_dev_role]), - *self.get_message_reaction(valid_emoji), - helpers.MockMember(id=77, bot=True, roles=[self.core_dev_role]), - "reactor is a bot", - ), - ( - helpers.MockMember(id=77), - helpers.MockMessage(id=95), - helpers.MockReaction(emoji=valid_emoji, message=helpers.MockMessage(id=26)), - helpers.MockMember(id=77), - "messages are not identical", - ), - ( - helpers.MockMember(id=77), - *self.get_message_reaction("InVaLiD"), - helpers.MockMember(id=77), - "emoji is invalid", - ), - ) - - for *args, msg in subtests: - kwargs = dict(zip(("author", "message", "reaction", "user"), args)) - with self.subTest(**kwargs, msg=msg): - ret_val = self.syncer._reaction_check(*args) - self.assertFalse(ret_val) - - async def test_wait_for_confirmation(self): - """The message should always be edited and only return True if the emoji is a check mark.""" - subtests = ( - (constants.Emojis.check_mark, True, None), - ("InVaLiD", False, None), - (None, False, asyncio.TimeoutError), - ) - - for emoji, ret_val, side_effect in subtests: - for bot in (True, False): - with self.subTest(emoji=emoji, ret_val=ret_val, side_effect=side_effect, bot=bot): - # Set up mocks - message = helpers.MockMessage() - member = helpers.MockMember(bot=bot) - - self.bot.wait_for.reset_mock() - self.bot.wait_for.return_value = (helpers.MockReaction(emoji=emoji), None) - self.bot.wait_for.side_effect = side_effect - - # Call the function - actual_return = await self.syncer._wait_for_confirmation(member, message) - - # Perform assertions - self.bot.wait_for.assert_called_once() - self.assertIn("reaction_add", self.bot.wait_for.call_args[0]) - - message.edit.assert_called_once() - kwargs = message.edit.call_args[1] - self.assertIn("content", kwargs) - - # Core devs should only be mentioned if the author is a bot. - if bot: - self.assertIn(self.syncer._CORE_DEV_MENTION, kwargs["content"]) - else: - self.assertNotIn(self.syncer._CORE_DEV_MENTION, kwargs["content"]) - - self.assertIs(actual_return, ret_val) - - class SyncerSyncTests(unittest.IsolatedAsyncioTestCase): """Tests for main function orchestrating the sync.""" def setUp(self): self.bot = helpers.MockBot(user=helpers.MockMember(bot=True)) self.syncer = TestSyncer(self.bot) + self.guild = helpers.MockGuild() - async def test_sync_respects_confirmation_result(self): - """The sync should abort if confirmation fails and continue if confirmed.""" - mock_message = helpers.MockMessage() - subtests = ( - (True, mock_message), - (False, None), - ) - - for confirmed, message in subtests: - with self.subTest(confirmed=confirmed): - self.syncer._sync.reset_mock() - self.syncer._get_diff.reset_mock() - - diff = _Diff({1, 2, 3}, {4, 5}, None) - self.syncer._get_diff.return_value = diff - self.syncer._get_confirmation_result = mock.AsyncMock( - return_value=(confirmed, message) - ) - - guild = helpers.MockGuild() - await self.syncer.sync(guild) - - self.syncer._get_diff.assert_called_once_with(guild) - self.syncer._get_confirmation_result.assert_called_once() - - if confirmed: - self.syncer._sync.assert_called_once_with(diff) - else: - self.syncer._sync.assert_not_called() - - async def test_sync_diff_size(self): - """The diff size should be correctly calculated.""" - subtests = ( - (6, _Diff({1, 2}, {3, 4}, {5, 6})), - (5, _Diff({1, 2, 3}, None, {4, 5})), - (0, _Diff(None, None, None)), - (0, _Diff(set(), set(), set())), - ) - - for size, diff in subtests: - with self.subTest(size=size, diff=diff): - self.syncer._get_diff.reset_mock() - self.syncer._get_diff.return_value = diff - self.syncer._get_confirmation_result = mock.AsyncMock(return_value=(False, None)) - - guild = helpers.MockGuild() - await self.syncer.sync(guild) - - self.syncer._get_diff.assert_called_once_with(guild) - self.syncer._get_confirmation_result.assert_called_once() - self.assertEqual(self.syncer._get_confirmation_result.call_args[0][0], size) + # Make sure `_get_diff` returns a MagicMock, not an AsyncMock + self.syncer._get_diff.return_value = mock.MagicMock() async def test_sync_message_edited(self): """The message should be edited if one was sent, even if the sync has an API error.""" @@ -316,89 +49,23 @@ class SyncerSyncTests(unittest.IsolatedAsyncioTestCase): for message, side_effect, should_edit in subtests: with self.subTest(message=message, side_effect=side_effect, should_edit=should_edit): self.syncer._sync.side_effect = side_effect - self.syncer._get_confirmation_result = mock.AsyncMock( - return_value=(True, message) - ) - guild = helpers.MockGuild() - await self.syncer.sync(guild) + await self.syncer.sync(self.guild) if should_edit: message.edit.assert_called_once() self.assertIn("content", message.edit.call_args[1]) - async def test_sync_confirmation_context_redirect(self): - """If ctx is given, a new message should be sent and author should be ctx's author.""" - mock_member = helpers.MockMember() + async def test_sync_message_sent(self): + """If ctx is given, a new message should be sent.""" subtests = ( - (None, self.bot.user, None), - (helpers.MockContext(author=mock_member), mock_member, helpers.MockMessage()), + (None, None), + (helpers.MockContext(), helpers.MockMessage()), ) - for ctx, author, message in subtests: - with self.subTest(ctx=ctx, author=author, message=message): - if ctx is not None: - ctx.send.return_value = message - - # Make sure `_get_diff` returns a MagicMock, not an AsyncMock - self.syncer._get_diff.return_value = mock.MagicMock() - - self.syncer._get_confirmation_result = mock.AsyncMock(return_value=(False, None)) - - guild = helpers.MockGuild() - await self.syncer.sync(guild, ctx) + for ctx, message in subtests: + with self.subTest(ctx=ctx, message=message): + await self.syncer.sync(self.guild, ctx) if ctx is not None: ctx.send.assert_called_once() - - self.syncer._get_confirmation_result.assert_called_once() - self.assertEqual(self.syncer._get_confirmation_result.call_args[0][1], author) - self.assertEqual(self.syncer._get_confirmation_result.call_args[0][2], message) - - @mock.patch.object(constants.Sync, "max_diff", new=3) - async def test_confirmation_result_small_diff(self): - """Should always return True and the given message if the diff size is too small.""" - author = helpers.MockMember() - expected_message = helpers.MockMessage() - - for size in (3, 2): # pragma: no cover - with self.subTest(size=size): - self.syncer._send_prompt = mock.AsyncMock() - self.syncer._wait_for_confirmation = mock.AsyncMock() - - coro = self.syncer._get_confirmation_result(size, author, expected_message) - result, actual_message = await coro - - self.assertTrue(result) - self.assertEqual(actual_message, expected_message) - self.syncer._send_prompt.assert_not_called() - self.syncer._wait_for_confirmation.assert_not_called() - - @mock.patch.object(constants.Sync, "max_diff", new=3) - async def test_confirmation_result_large_diff(self): - """Should return True if confirmed and False if _send_prompt fails or aborted.""" - author = helpers.MockMember() - mock_message = helpers.MockMessage() - - subtests = ( - (True, mock_message, True, "confirmed"), - (False, None, False, "_send_prompt failed"), - (False, mock_message, False, "aborted"), - ) - - for expected_result, expected_message, confirmed, msg in subtests: # pragma: no cover - with self.subTest(msg=msg): - self.syncer._send_prompt = mock.AsyncMock(return_value=expected_message) - self.syncer._wait_for_confirmation = mock.AsyncMock(return_value=confirmed) - - coro = self.syncer._get_confirmation_result(4, author) - actual_result, actual_message = await coro - - self.syncer._send_prompt.assert_called_once_with(None) # message defaults to None - self.assertIs(actual_result, expected_result) - self.assertEqual(actual_message, expected_message) - - if expected_message: - self.syncer._wait_for_confirmation.assert_called_once_with( - author, expected_message - ) -- cgit v1.2.3 From 9de2bbec0c1ab1462b5fd3b6e59b544060b3d472 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 7 Sep 2020 16:01:47 -0700 Subject: Fix test for sync message being edited --- tests/bot/cogs/sync/test_base.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index c3456f724..8d6f48333 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -49,8 +49,10 @@ class SyncerSyncTests(unittest.IsolatedAsyncioTestCase): for message, side_effect, should_edit in subtests: with self.subTest(message=message, side_effect=side_effect, should_edit=should_edit): self.syncer._sync.side_effect = side_effect + ctx = helpers.MockContext() + ctx.send.return_value = message - await self.syncer.sync(self.guild) + await self.syncer.sync(self.guild, ctx) if should_edit: message.edit.assert_called_once() -- cgit v1.2.3 From ab4eb39fcdde62cc9f558f9e41a5f48d9a587d38 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Tue, 8 Sep 2020 12:04:20 +0800 Subject: Add everyone_ping filter. --- bot/cogs/filtering.py | 38 +++++++++++++++++++++++++++++++++++--- bot/constants.py | 2 ++ config-default.yml | 18 ++++++++++-------- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 99b659bff..aa0cbad97 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -15,8 +15,8 @@ from bot.api import ResponseCodeError from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.constants import ( - Channels, Colours, - Filter, Icons, URLs + Channels, Colours, Filter, + Guild, Icons, URLs ) from bot.utils.redis_cache import RedisCache from bot.utils.regex import INVITE_RE @@ -25,6 +25,12 @@ from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) # Regular expressions +CODE_BLOCK_RE = re.compile( + r"(?P``?)[^`]+?(?P=delim)(?!`+)" # Inline codeblock + r"|```(.+?)```", # Multiline codeblock + re.DOTALL | re.MULTILINE +) +EVERYONE_PING_RE = re.compile(rf"@everyone|<@&{Guild.id}>|@here") SPOILER_RE = re.compile(r"(\|\|.+?\|\|)", re.DOTALL) URL_RE = re.compile(r"(https?://[^\s]+)", flags=re.IGNORECASE) ZALGO_RE = re.compile(r"[\u0300-\u036F\u0489]") @@ -82,6 +88,19 @@ class Filtering(Cog): ), "schedule_deletion": False }, + "filter_everyone_ping": { + "enabled": Filter.filter_everyone_ping, + "function": self._has_everyone_ping, + "type": "filter", + "content_only": True, + "user_notification": Filter.notify_user_everyone_ping, + "notification_msg": ( + "Please don't try to ping `@everyone` or `@here`. " + f"Your message has been removed. {staff_mistake_str}" + ), + "schedule_deletion": False, + "ping_everyone": False + }, "watch_regex": { "enabled": Filter.watch_regex, "function": self._has_watch_regex_match, @@ -332,6 +351,9 @@ class Filtering(Cog): log.debug(message) + # Allow specific filters to override ping_everyone + ping_everyone = Filter.ping_everyone and _filter.get("ping_everyone", True) + # Send pretty mod log embed to mod-alerts await self.mod_log.send_log_message( icon_url=Icons.filtering, @@ -340,7 +362,7 @@ class Filtering(Cog): text=message, thumbnail=msg.author.avatar_url_as(static_format="png"), channel_id=Channels.mod_alerts, - ping_everyone=Filter.ping_everyone if not is_private else False, + ping_everyone=ping_everyone if not is_private else False, additional_embeds=additional_embeds, additional_embeds_msg=additional_embeds_msg ) @@ -528,6 +550,16 @@ class Filtering(Cog): return False return False + @staticmethod + async def _has_everyone_ping(text: str) -> bool: + """Determines if `msg` contains an @everyone or @here ping outside of a codeblock.""" + # First pass to avoid running re.sub on every message + if not EVERYONE_PING_RE.search(text): + return False + + content_without_codeblocks = CODE_BLOCK_RE.sub("", text) + return bool(EVERYONE_PING_RE.search(content_without_codeblocks)) + async def notify_member(self, filtered_member: Member, reason: str, channel: TextChannel) -> None: """ Notify filtered_member about a moderation action with the reason str. diff --git a/bot/constants.py b/bot/constants.py index 17fe34e95..70b36984f 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -217,6 +217,7 @@ class Filter(metaclass=YAMLGetter): filter_zalgo: bool filter_invites: bool filter_domains: bool + filter_everyone_ping: bool watch_regex: bool watch_rich_embeds: bool @@ -224,6 +225,7 @@ class Filter(metaclass=YAMLGetter): notify_user_zalgo: bool notify_user_invites: bool notify_user_domains: bool + notify_user_everyone_ping: bool ping_everyone: bool offensive_msg_delete_days: int diff --git a/config-default.yml b/config-default.yml index c1eef713f..cf9ce8798 100644 --- a/config-default.yml +++ b/config-default.yml @@ -273,17 +273,19 @@ guild: filter: # What do we filter? - filter_zalgo: false - filter_invites: true - filter_domains: true - watch_regex: true - watch_rich_embeds: true + filter_zalgo: false + filter_invites: true + filter_domains: true + filter_everyone_ping: true + watch_regex: true + watch_rich_embeds: true # Notify user on filter? # Notifications are not expected for "watchlist" type filters - notify_user_zalgo: false - notify_user_invites: true - notify_user_domains: false + notify_user_zalgo: false + notify_user_invites: true + notify_user_domains: false + notify_user_everyone_ping: true # Filter configuration ping_everyone: true -- cgit v1.2.3 From f7d436dfba90d1e8e92c2bf32440098aa266fbd3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 6 Sep 2020 09:11:17 -0700 Subject: Rename role checks and decorators Make their names more in line with `has_any_role` for consistency. --- bot/cogs/information.py | 6 +++--- bot/cogs/reminders.py | 10 +++++----- bot/cogs/verification.py | 8 ++++---- bot/decorators.py | 2 +- bot/utils/checks.py | 4 ++-- tests/bot/utils/test_checks.py | 36 ++++++++++++++++++------------------ 6 files changed, 33 insertions(+), 33 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 5b132a469..581b3a227 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -15,7 +15,7 @@ from bot import constants from bot.bot import Bot from bot.decorators import in_whitelist from bot.pagination import LinePaginator -from bot.utils.checks import InWhitelistCheckFailure, cooldown_with_role_bypass, without_role_check +from bot.utils.checks import InWhitelistCheckFailure, cooldown_with_role_bypass, has_no_roles_check from bot.utils.time import time_since log = logging.getLogger(__name__) @@ -198,12 +198,12 @@ class Information(Cog): user = ctx.author # Do a role check if this is being executed on someone other than the caller - elif user != ctx.author and await without_role_check(ctx, *constants.MODERATION_ROLES): + elif user != ctx.author and await has_no_roles_check(ctx, *constants.MODERATION_ROLES): await ctx.send("You may not use this command on users other than yourself.") return # Non-staff may only do this in #bot-commands - if await without_role_check(ctx, *constants.STAFF_ROLES): + if await has_no_roles_check(ctx, *constants.STAFF_ROLES): if not ctx.channel.id == constants.Channels.bot_commands: raise InWhitelistCheckFailure(constants.Channels.bot_commands) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index d7357e3fa..6806f2889 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -15,7 +15,7 @@ from bot.bot import Bot from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Roles, STAFF_ROLES from bot.converters import Duration from bot.pagination import LinePaginator -from bot.utils.checks import with_role_check, without_role_check +from bot.utils.checks import has_any_role_check, has_no_roles_check from bot.utils.messages import send_denial from bot.utils.scheduling import Scheduler from bot.utils.time import humanize_delta @@ -117,9 +117,9 @@ class Reminders(Cog): If mentions aren't allowed, also return the type of mention(s) disallowed. """ - if await without_role_check(ctx, *STAFF_ROLES): + if await has_no_roles_check(ctx, *STAFF_ROLES): return False, "members/roles" - elif await without_role_check(ctx, *MODERATION_ROLES): + elif await has_no_roles_check(ctx, *MODERATION_ROLES): return all(isinstance(mention, discord.Member) for mention in mentions), "roles" else: return True, "" @@ -240,7 +240,7 @@ class Reminders(Cog): Expiration is parsed per: http://strftime.org/ """ # If the user is not staff, we need to verify whether or not to make a reminder at all. - if await without_role_check(ctx, *STAFF_ROLES): + if await has_no_roles_check(ctx, *STAFF_ROLES): # If they don't have permission to set a reminder in this channel if ctx.channel.id not in WHITELISTED_CHANNELS: @@ -431,7 +431,7 @@ class Reminders(Cog): The check passes when the user is an admin, or if they created the reminder. """ - if await with_role_check(ctx, Roles.admins): + if await has_any_role_check(ctx, Roles.admins): return True api_response = await self.bot.api_client.get(f"bot/reminders/{reminder_id}") diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index afbe1d3b8..300c7f315 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -7,8 +7,8 @@ from discord.ext.commands import Cog, Context, command from bot import constants from bot.bot import Bot from bot.cogs.moderation import ModLog -from bot.decorators import in_whitelist, without_role -from bot.utils.checks import InWhitelistCheckFailure, without_role_check +from bot.decorators import has_no_roles, in_whitelist +from bot.utils.checks import InWhitelistCheckFailure, has_no_roles_check log = logging.getLogger(__name__) @@ -107,7 +107,7 @@ class Verification(Cog): await ctx.message.delete() @command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True) - @without_role(constants.Roles.verified) + @has_no_roles(constants.Roles.verified) @in_whitelist(channels=(constants.Channels.verification,)) async def accept_command(self, ctx: Context, *_) -> None: # We don't actually care about the args """Accept our rules and gain access to the rest of the server.""" @@ -181,7 +181,7 @@ class Verification(Cog): async def bot_check(ctx: Context) -> bool: """Block any command within the verification channel that is not !accept.""" is_verification = ctx.channel.id == constants.Channels.verification - if is_verification and await without_role_check(ctx, *constants.MODERATION_ROLES): + if is_verification and await has_no_roles_check(ctx, *constants.MODERATION_ROLES): return ctx.command.name == "accept" else: return True diff --git a/bot/decorators.py b/bot/decorators.py index 56028ad8a..2518124da 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -44,7 +44,7 @@ def in_whitelist( return commands.check(predicate) -def without_role(*roles: Union[str, int]) -> Callable: +def has_no_roles(*roles: Union[str, int]) -> Callable: """ Returns True if the user does not have any of the roles specified. diff --git a/bot/utils/checks.py b/bot/utils/checks.py index c2e41efb3..460a937d8 100644 --- a/bot/utils/checks.py +++ b/bot/utils/checks.py @@ -91,7 +91,7 @@ def in_whitelist_check( return False -async def with_role_check(ctx: Context, *roles: Union[str, int]) -> bool: +async def has_any_role_check(ctx: Context, *roles: Union[str, int]) -> bool: """ Returns True if the context's author has any of the specified roles. @@ -104,7 +104,7 @@ async def with_role_check(ctx: Context, *roles: Union[str, int]) -> bool: return False -async def without_role_check(ctx: Context, *roles: Union[str, int]) -> bool: +async def has_no_roles_check(ctx: Context, *roles: Union[str, int]) -> bool: """ Returns True if the context's author doesn't have any of the specified roles. diff --git a/tests/bot/utils/test_checks.py b/tests/bot/utils/test_checks.py index de72e5748..dfee2cf91 100644 --- a/tests/bot/utils/test_checks.py +++ b/tests/bot/utils/test_checks.py @@ -12,37 +12,37 @@ class ChecksTests(unittest.TestCase): def setUp(self): self.ctx = MockContext() - def test_with_role_check_without_guild(self): - """`with_role_check` returns `False` if `Context.guild` is None.""" + def test_has_any_role_check_without_guild(self): + """`has_any_role_check` returns `False` if `Context.guild` is None.""" self.ctx.guild = None - self.assertFalse(checks.with_role_check(self.ctx)) + self.assertFalse(checks.has_any_role_check(self.ctx)) - def test_with_role_check_without_required_roles(self): - """`with_role_check` returns `False` if `Context.author` lacks the required role.""" + def test_has_any_role_check_without_required_roles(self): + """`has_any_role_check` returns `False` if `Context.author` lacks the required role.""" self.ctx.author.roles = [] - self.assertFalse(checks.with_role_check(self.ctx)) + self.assertFalse(checks.has_any_role_check(self.ctx)) - def test_with_role_check_with_guild_and_required_role(self): - """`with_role_check` returns `True` if `Context.author` has the required role.""" + def test_has_any_role_check_with_guild_and_required_role(self): + """`has_any_role_check` returns `True` if `Context.author` has the required role.""" self.ctx.author.roles.append(MockRole(id=10)) - self.assertTrue(checks.with_role_check(self.ctx, 10)) + self.assertTrue(checks.has_any_role_check(self.ctx, 10)) - def test_without_role_check_without_guild(self): - """`without_role_check` should return `False` when `Context.guild` is None.""" + def test_has_no_roles_check_without_guild(self): + """`has_no_roles_check` should return `False` when `Context.guild` is None.""" self.ctx.guild = None - self.assertFalse(checks.without_role_check(self.ctx)) + self.assertFalse(checks.has_no_roles_check(self.ctx)) - def test_without_role_check_returns_false_with_unwanted_role(self): - """`without_role_check` returns `False` if `Context.author` has unwanted role.""" + def test_has_no_roles_check_returns_false_with_unwanted_role(self): + """`has_no_roles_check` returns `False` if `Context.author` has unwanted role.""" role_id = 42 self.ctx.author.roles.append(MockRole(id=role_id)) - self.assertFalse(checks.without_role_check(self.ctx, role_id)) + self.assertFalse(checks.has_no_roles_check(self.ctx, role_id)) - def test_without_role_check_returns_true_without_unwanted_role(self): - """`without_role_check` returns `True` if `Context.author` does not have unwanted role.""" + def test_has_no_roles_check_returns_true_without_unwanted_role(self): + """`has_no_roles_check` returns `True` if `Context.author` does not have unwanted role.""" role_id = 42 self.ctx.author.roles.append(MockRole(id=role_id)) - self.assertTrue(checks.without_role_check(self.ctx, role_id + 10)) + self.assertTrue(checks.has_no_roles_check(self.ctx, role_id + 10)) def test_in_whitelist_check_correct_channel(self): """`in_whitelist_check` returns `True` if `Context.channel.id` is in the channel list.""" -- cgit v1.2.3 From 7741e9fd9054cb302e2d65afc8303db01c8eed7e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 6 Sep 2020 09:14:11 -0700 Subject: Fix tests for has_any_role_check --- tests/bot/utils/test_checks.py | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/tests/bot/utils/test_checks.py b/tests/bot/utils/test_checks.py index dfee2cf91..883465e0b 100644 --- a/tests/bot/utils/test_checks.py +++ b/tests/bot/utils/test_checks.py @@ -1,48 +1,50 @@ import unittest from unittest.mock import MagicMock +from discord import DMChannel + from bot.utils import checks from bot.utils.checks import InWhitelistCheckFailure from tests.helpers import MockContext, MockRole -class ChecksTests(unittest.TestCase): +class ChecksTests(unittest.IsolatedAsyncioTestCase): """Tests the check functions defined in `bot.checks`.""" def setUp(self): self.ctx = MockContext() - def test_has_any_role_check_without_guild(self): - """`has_any_role_check` returns `False` if `Context.guild` is None.""" - self.ctx.guild = None - self.assertFalse(checks.has_any_role_check(self.ctx)) + async def test_has_any_role_check_without_guild(self): + """`has_any_role_check` returns `False` for non-guild channels.""" + self.ctx.channel = MagicMock(DMChannel) + self.assertFalse(await checks.has_any_role_check(self.ctx)) - def test_has_any_role_check_without_required_roles(self): + async def test_has_any_role_check_without_required_roles(self): """`has_any_role_check` returns `False` if `Context.author` lacks the required role.""" self.ctx.author.roles = [] - self.assertFalse(checks.has_any_role_check(self.ctx)) + self.assertFalse(await checks.has_any_role_check(self.ctx)) - def test_has_any_role_check_with_guild_and_required_role(self): + async def test_has_any_role_check_with_guild_and_required_role(self): """`has_any_role_check` returns `True` if `Context.author` has the required role.""" self.ctx.author.roles.append(MockRole(id=10)) - self.assertTrue(checks.has_any_role_check(self.ctx, 10)) + self.assertTrue(await checks.has_any_role_check(self.ctx, 10)) - def test_has_no_roles_check_without_guild(self): + async def test_has_no_roles_check_without_guild(self): """`has_no_roles_check` should return `False` when `Context.guild` is None.""" - self.ctx.guild = None - self.assertFalse(checks.has_no_roles_check(self.ctx)) + self.ctx.channel = MagicMock(DMChannel) + self.assertFalse(await checks.has_no_roles_check(self.ctx)) - def test_has_no_roles_check_returns_false_with_unwanted_role(self): + async def test_has_no_roles_check_returns_false_with_unwanted_role(self): """`has_no_roles_check` returns `False` if `Context.author` has unwanted role.""" role_id = 42 self.ctx.author.roles.append(MockRole(id=role_id)) - self.assertFalse(checks.has_no_roles_check(self.ctx, role_id)) + self.assertFalse(await checks.has_no_roles_check(self.ctx, role_id)) - def test_has_no_roles_check_returns_true_without_unwanted_role(self): + async def test_has_no_roles_check_returns_true_without_unwanted_role(self): """`has_no_roles_check` returns `True` if `Context.author` does not have unwanted role.""" role_id = 42 self.ctx.author.roles.append(MockRole(id=role_id)) - self.assertTrue(checks.has_no_roles_check(self.ctx, role_id + 10)) + self.assertTrue(await checks.has_no_roles_check(self.ctx, role_id + 10)) def test_in_whitelist_check_correct_channel(self): """`in_whitelist_check` returns `True` if `Context.channel.id` is in the channel list.""" -- cgit v1.2.3 From 39b6e25ef5b50bfcd16ec1843b4abb72bf125a35 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 8 Sep 2020 10:32:22 -0700 Subject: Fix cog_check tests --- tests/bot/cogs/moderation/test_silence.py | 10 ++++++---- tests/bot/cogs/test_slowmode.py | 10 ++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index ab3d0742a..5a664b1f8 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -253,9 +253,11 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.cog.cog_unload() asyncio_mock.create_task.assert_not_called() - @mock.patch("bot.cogs.moderation.silence.with_role_check") + @mock.patch("discord.ext.commands.has_any_role") @mock.patch("bot.cogs.moderation.silence.MODERATION_ROLES", new=(1, 2, 3)) - def test_cog_check(self, role_check): + async def test_cog_check(self, role_check): """Role check is called with `MODERATION_ROLES`""" - self.cog.cog_check(self.ctx) - role_check.assert_called_once_with(self.ctx, *(1, 2, 3)) + role_check.return_value.predicate = mock.AsyncMock() + await self.cog.cog_check(self.ctx) + role_check.assert_called_once_with(*(1, 2, 3)) + role_check.return_value.predicate.assert_awaited_once_with(self.ctx) diff --git a/tests/bot/cogs/test_slowmode.py b/tests/bot/cogs/test_slowmode.py index f442814c8..5e6d6a26a 100644 --- a/tests/bot/cogs/test_slowmode.py +++ b/tests/bot/cogs/test_slowmode.py @@ -103,9 +103,11 @@ class SlowmodeTests(unittest.IsolatedAsyncioTestCase): f'{Emojis.check_mark} The slowmode delay for #meta has been reset to 0 seconds.' ) - @mock.patch("bot.cogs.moderation.slowmode.with_role_check") + @mock.patch("bot.cogs.moderation.slowmode.has_any_role") @mock.patch("bot.cogs.moderation.slowmode.MODERATION_ROLES", new=(1, 2, 3)) - def test_cog_check(self, role_check): + async def test_cog_check(self, role_check): """Role check is called with `MODERATION_ROLES`""" - self.cog.cog_check(self.ctx) - role_check.assert_called_once_with(self.ctx, *(1, 2, 3)) + role_check.return_value.predicate = mock.AsyncMock() + await self.cog.cog_check(self.ctx) + role_check.assert_called_once_with(*(1, 2, 3)) + role_check.return_value.predicate.assert_awaited_once_with(self.ctx) -- cgit v1.2.3 From 6556a5cc02c391657e26950ede84a2fb7e4f679e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 9 Sep 2020 17:23:26 -0700 Subject: Decorators: remove locked() decorator It was not being used anywhere. --- bot/decorators.py | 37 ++----------------------------------- 1 file changed, 2 insertions(+), 35 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index 3418dfd11..333716cf5 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -1,17 +1,16 @@ import asyncio import inspect import logging -import random import typing as t from collections import defaultdict from contextlib import suppress from functools import partial, wraps from weakref import WeakValueDictionary -from discord import Colour, Embed, Member, NotFound +from discord import Member, NotFound from discord.ext.commands import Cog, Context, check -from bot.constants import Channels, ERROR_REPLIES, RedirectOutput +from bot.constants import Channels, RedirectOutput from bot.errors import LockedResourceError from bot.utils import LockGuard, function from bot.utils.checks import in_whitelist_check, with_role_check, without_role_check @@ -67,38 +66,6 @@ def without_role(*role_ids: int) -> t.Callable: return check(predicate) -def locked() -> t.Callable: - """ - Allows the user to only run one instance of the decorated command at a time. - - Subsequent calls to the command from the same author are ignored until the command has completed invocation. - - This decorator must go before (below) the `command` decorator. - """ - def wrap(func: t.Callable) -> t.Callable: - func.__locks = WeakValueDictionary() - - @wraps(func) - async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None: - lock = func.__locks.setdefault(ctx.author.id, asyncio.Lock()) - if lock.locked(): - embed = Embed() - embed.colour = Colour.red() - - log.debug("User tried to invoke a locked command.") - embed.description = ( - "You're already using this command. Please wait until it is done before you use it again." - ) - embed.title = random.choice(ERROR_REPLIES) - await ctx.send(embed=embed) - return - - async with func.__locks.setdefault(ctx.author.id, asyncio.Lock()): - await func(self, ctx, *args, **kwargs) - return inner - return wrap - - def mutually_exclusive( namespace: t.Hashable, resource_id: ResourceId, -- cgit v1.2.3 From 85e4d910da9fafb67f46330e4446e83a738d7a9b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Aug 2020 10:10:22 -0700 Subject: Decorators: rename mutually_exclusive decorators A mutex is the same thing as a lock. The former is a relatively esoteric contraction, so the latter is preferred. --- bot/cogs/reminders.py | 8 ++++---- bot/decorators.py | 15 +++++---------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index be97d34b6..734e0bd2d 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -14,7 +14,7 @@ from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, STAFF_ROLES from bot.converters import Duration -from bot.decorators import mutually_exclusive_arg +from bot.decorators import lock_arg from bot.pagination import LinePaginator from bot.utils.checks import without_role_check from bot.utils.messages import send_denial @@ -166,7 +166,7 @@ class Reminders(Cog): log.trace(f"Scheduling new task #{reminder['id']}") self.schedule_reminder(reminder) - @mutually_exclusive_arg(NAMESPACE, "reminder", itemgetter("id"), raise_error=True) + @lock_arg(NAMESPACE, "reminder", itemgetter("id"), raise_error=True) async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: """Send the reminder.""" is_valid, user, channel = self.ensure_valid_reminder(reminder) @@ -373,7 +373,7 @@ class Reminders(Cog): mention_ids = [mention.id for mention in mentions] await self.edit_reminder(ctx, id_, {"mentions": mention_ids}) - @mutually_exclusive_arg(NAMESPACE, "id_", raise_error=True) + @lock_arg(NAMESPACE, "id_", raise_error=True) async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: """Edits a reminder with the given payload, then sends a confirmation message.""" reminder = await self._edit_reminder(id_, payload) @@ -391,7 +391,7 @@ class Reminders(Cog): await self._reschedule_reminder(reminder) @remind_group.command("delete", aliases=("remove", "cancel")) - @mutually_exclusive_arg(NAMESPACE, "id_", raise_error=True) + @lock_arg(NAMESPACE, "id_", raise_error=True) async def delete_reminder(self, ctx: Context, id_: int) -> None: """Delete one of your active reminders.""" await self.bot.api_client.delete(f"bot/reminders/{id_}") diff --git a/bot/decorators.py b/bot/decorators.py index 333716cf5..aabbe2cc9 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -66,12 +66,7 @@ def without_role(*role_ids: int) -> t.Callable: return check(predicate) -def mutually_exclusive( - namespace: t.Hashable, - resource_id: ResourceId, - *, - raise_error: bool = False, -) -> t.Callable: +def lock(namespace: t.Hashable, resource_id: ResourceId, *, raise_error: bool = False) -> t.Callable: """ Turn the decorated coroutine function into a mutually exclusive operation on a `resource_id`. @@ -126,7 +121,7 @@ def mutually_exclusive( return decorator -def mutually_exclusive_arg( +def lock_arg( namespace: t.Hashable, name_or_pos: function.Argument, func: t.Callable[[t.Any], _IdCallableReturn] = None, @@ -134,12 +129,12 @@ def mutually_exclusive_arg( raise_error: bool = False, ) -> t.Callable: """ - Apply `mutually_exclusive` using the value of the arg at the given name/position as the ID. + Apply the `lock` decorator using the value of the arg at the given name/position as the ID. `func` is an optional callable or awaitable which will return the ID given the argument value. - See `mutually_exclusive` docs for more information. + See `lock` docs for more information. """ - decorator_func = partial(mutually_exclusive, namespace, raise_error=raise_error) + decorator_func = partial(lock, namespace, raise_error=raise_error) return function.get_arg_value_wrapper(decorator_func, name_or_pos, func) -- cgit v1.2.3 From ac25ada30f4e43c130e0183be16ad6eef41c44d8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Aug 2020 10:23:21 -0700 Subject: Move lock decorators to utils/lock.py `LockGuard` was lonely and the decorators were cluttering up decorators.py. --- bot/cogs/reminders.py | 2 +- bot/decorators.py | 85 ++---------------------------------------------- bot/utils/__init__.py | 3 +- bot/utils/lock.py | 90 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 86 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 734e0bd2d..25b2c9421 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -14,9 +14,9 @@ from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, STAFF_ROLES from bot.converters import Duration -from bot.decorators import lock_arg from bot.pagination import LinePaginator from bot.utils.checks import without_role_check +from bot.utils.lock import lock_arg from bot.utils.messages import send_denial from bot.utils.scheduling import Scheduler from bot.utils.time import humanize_delta diff --git a/bot/decorators.py b/bot/decorators.py index aabbe2cc9..2ec0cb122 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -1,26 +1,17 @@ import asyncio -import inspect import logging import typing as t -from collections import defaultdict from contextlib import suppress -from functools import partial, wraps -from weakref import WeakValueDictionary +from functools import wraps from discord import Member, NotFound from discord.ext.commands import Cog, Context, check from bot.constants import Channels, RedirectOutput -from bot.errors import LockedResourceError -from bot.utils import LockGuard, function +from bot.utils import function from bot.utils.checks import in_whitelist_check, with_role_check, without_role_check log = logging.getLogger(__name__) -__lock_dicts = defaultdict(WeakValueDictionary) - -_IdCallableReturn = t.Union[t.Hashable, t.Awaitable[t.Hashable]] -_IdCallable = t.Callable[[function.BoundArgs], _IdCallableReturn] -ResourceId = t.Union[t.Hashable, _IdCallable] def in_whitelist( @@ -66,78 +57,6 @@ def without_role(*role_ids: int) -> t.Callable: return check(predicate) -def lock(namespace: t.Hashable, resource_id: ResourceId, *, raise_error: bool = False) -> t.Callable: - """ - Turn the decorated coroutine function into a mutually exclusive operation on a `resource_id`. - - If any other mutually exclusive function currently holds the lock for a resource, do not run the - decorated function and return None. If `raise_error` is True, raise `LockedResourceError` if - the lock cannot be acquired. - - `namespace` is an identifier used to prevent collisions among resource IDs. - - `resource_id` identifies a resource on which to perform a mutually exclusive operation. - It may also be a callable or awaitable which will return the resource ID given an ordered - mapping of the parameters' names to arguments' values. - - If decorating a command, this decorator must go before (below) the `command` decorator. - """ - def decorator(func: t.Callable) -> t.Callable: - name = func.__name__ - - @wraps(func) - async def wrapper(*args, **kwargs) -> t.Any: - log.trace(f"{name}: mutually exclusive decorator called") - - if callable(resource_id): - log.trace(f"{name}: binding args to signature") - bound_args = function.get_bound_args(func, args, kwargs) - - log.trace(f"{name}: calling the given callable to get the resource ID") - id_ = resource_id(bound_args) - - if inspect.isawaitable(id_): - log.trace(f"{name}: awaiting to get resource ID") - id_ = await id_ - else: - id_ = resource_id - - log.trace(f"{name}: getting lock for resource {id_!r} under namespace {namespace!r}") - - # Get the lock for the ID. Create a lock if one doesn't exist yet. - locks = __lock_dicts[namespace] - lock = locks.setdefault(id_, LockGuard()) - - if not lock.locked(): - log.debug(f"{name}: resource {namespace!r}:{id_!r} is free; acquiring it...") - with lock: - return await func(*args, **kwargs) - else: - log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked") - if raise_error: - raise LockedResourceError(str(namespace), id_) - - return wrapper - return decorator - - -def lock_arg( - namespace: t.Hashable, - name_or_pos: function.Argument, - func: t.Callable[[t.Any], _IdCallableReturn] = None, - *, - raise_error: bool = False, -) -> t.Callable: - """ - Apply the `lock` decorator using the value of the arg at the given name/position as the ID. - - `func` is an optional callable or awaitable which will return the ID given the argument value. - See `lock` docs for more information. - """ - decorator_func = partial(lock, namespace, raise_error=raise_error) - return function.get_arg_value_wrapper(decorator_func, name_or_pos, func) - - def redirect_output(destination_channel: int, bypass_roles: t.Container[int] = None) -> t.Callable: """ Changes the channel in the context of the command to redirect the output to a certain channel. diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 0dd9605e8..b73410e96 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -2,10 +2,9 @@ from abc import ABCMeta from discord.ext.commands import CogMeta -from bot.utils.lock import LockGuard from bot.utils.redis_cache import RedisCache -__all__ = ["CogABCMeta", "LockGuard", "RedisCache"] +__all__ = ["CogABCMeta", "RedisCache"] class CogABCMeta(CogMeta, ABCMeta): diff --git a/bot/utils/lock.py b/bot/utils/lock.py index 8f1b738aa..5c9dd3725 100644 --- a/bot/utils/lock.py +++ b/bot/utils/lock.py @@ -1,3 +1,21 @@ +import inspect +import logging +from collections import defaultdict +from functools import partial, wraps +from typing import Any, Awaitable, Callable, Hashable, Union +from weakref import WeakValueDictionary + +from bot.errors import LockedResourceError +from bot.utils import function + +log = logging.getLogger(__name__) +__lock_dicts = defaultdict(WeakValueDictionary) + +_IdCallableReturn = Union[Hashable, Awaitable[Hashable]] +_IdCallable = Callable[[function.BoundArgs], _IdCallableReturn] +ResourceId = Union[Hashable, _IdCallable] + + class LockGuard: """ A context manager which acquires and releases a lock (mutex). @@ -21,3 +39,75 @@ class LockGuard: def __exit__(self, _exc_type, _exc_value, _traceback): # noqa: ANN001 self._locked = False return False # Indicate any raised exception shouldn't be suppressed. + + +def lock(namespace: Hashable, resource_id: ResourceId, *, raise_error: bool = False) -> Callable: + """ + Turn the decorated coroutine function into a mutually exclusive operation on a `resource_id`. + + If any other mutually exclusive function currently holds the lock for a resource, do not run the + decorated function and return None. If `raise_error` is True, raise `LockedResourceError` if + the lock cannot be acquired. + + `namespace` is an identifier used to prevent collisions among resource IDs. + + `resource_id` identifies a resource on which to perform a mutually exclusive operation. + It may also be a callable or awaitable which will return the resource ID given an ordered + mapping of the parameters' names to arguments' values. + + If decorating a command, this decorator must go before (below) the `command` decorator. + """ + def decorator(func: Callable) -> Callable: + name = func.__name__ + + @wraps(func) + async def wrapper(*args, **kwargs) -> Any: + log.trace(f"{name}: mutually exclusive decorator called") + + if callable(resource_id): + log.trace(f"{name}: binding args to signature") + bound_args = function.get_bound_args(func, args, kwargs) + + log.trace(f"{name}: calling the given callable to get the resource ID") + id_ = resource_id(bound_args) + + if inspect.isawaitable(id_): + log.trace(f"{name}: awaiting to get resource ID") + id_ = await id_ + else: + id_ = resource_id + + log.trace(f"{name}: getting lock for resource {id_!r} under namespace {namespace!r}") + + # Get the lock for the ID. Create a lock if one doesn't exist yet. + locks = __lock_dicts[namespace] + lock = locks.setdefault(id_, LockGuard()) + + if not lock.locked(): + log.debug(f"{name}: resource {namespace!r}:{id_!r} is free; acquiring it...") + with lock: + return await func(*args, **kwargs) + else: + log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked") + if raise_error: + raise LockedResourceError(str(namespace), id_) + + return wrapper + return decorator + + +def lock_arg( + namespace: Hashable, + name_or_pos: function.Argument, + func: Callable[[Any], _IdCallableReturn] = None, + *, + raise_error: bool = False, +) -> Callable: + """ + Apply the `lock` decorator using the value of the arg at the given name/position as the ID. + + `func` is an optional callable or awaitable which will return the ID given the argument value. + See `lock` docs for more information. + """ + decorator_func = partial(lock, namespace, raise_error=raise_error) + return function.get_arg_value_wrapper(decorator_func, name_or_pos, func) -- cgit v1.2.3 From fe08fd275d492637abc78071efe9441e7526e588 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 9 Sep 2020 17:31:21 -0700 Subject: Fix attribute docstring for LockedResourceError --- bot/errors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/errors.py b/bot/errors.py index 34de3c2b1..65d715203 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -7,7 +7,7 @@ class LockedResourceError(RuntimeError): Attributes: `type` -- name of the locked resource's type - `resource_id` -- ID of the locked resource + `id` -- ID of the locked resource """ def __init__(self, resource_type: str, resource_id: Hashable): -- cgit v1.2.3 From 1b3a5c5d90bb658895fea8a2bed91366d2f2f76e Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 10 Sep 2020 18:41:07 +0200 Subject: Verification: move constants to config --- bot/cogs/verification.py | 45 ++++++++++++++++++++------------------------- bot/constants.py | 10 ++++++++++ config-default.yml | 13 +++++++++++++ 3 files changed, 43 insertions(+), 25 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 08f7c282e..0092a0898 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -18,16 +18,6 @@ from bot.utils.redis_cache import RedisCache log = logging.getLogger(__name__) -UNVERIFIED_AFTER = 3 # Amount of days after which non-Developers receive the @Unverified role -KICKED_AFTER = 30 # Amount of days after which non-Developers get kicked from the guild - -# Number in range [0, 1] determining the percentage of unverified users that are safe -# to be kicked from the guild in one batch, any larger amount will require staff confirmation, -# set this to 0 to require explicit approval for batches of any size -KICK_CONFIRMATION_THRESHOLD = 0.01 # 1% - -BOT_MESSAGE_DELETE_DELAY = 10 - # Sent via DMs once user joins the guild ON_JOIN_MESSAGE = f""" Hello! Welcome to Python Discord! @@ -62,7 +52,7 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! # Sent via DMs to users kicked for failing to verify KICKED_MESSAGE = f""" Hi! You have been automatically kicked from Python Discord as you have failed to accept our rules \ -within `{KICKED_AFTER}` days. If this was an accident, please feel free to join us again! +within `{constants.Verification.kicked_after}` days. If this was an accident, please feel free to join us again! {constants.Guild.invite} """ @@ -74,11 +64,9 @@ REMINDER_MESSAGE = f""" Welcome to Python Discord! Please read the documents mentioned above and type `!accept` to gain permissions \ to send messages in the community! -You will be kicked if you don't verify within `{KICKED_AFTER}` days. +You will be kicked if you don't verify within `{constants.Verification.kicked_after}` days. """.strip() -REMINDER_FREQUENCY = 28 # Hours to wait between sending `REMINDER_MESSAGE` - # An async function taking a Member param Request = t.Callable[[discord.Member], t.Awaitable] @@ -209,7 +197,7 @@ class Verification(Cog): pydis = self.bot.get_guild(constants.Guild.id) percentage = n_members / len(pydis.members) - if percentage < KICK_CONFIRMATION_THRESHOLD: + if percentage < constants.Verification.kick_confirmation_threshold: log.debug(f"Kicking {percentage:.2%} of the guild's population is seen as safe") return True @@ -221,7 +209,8 @@ class Verification(Cog): confirmation_msg = await core_dev_channel.send( f"{core_dev_ping} Verification determined that `{n_members}` members should be kicked as they haven't " - f"verified in `{KICKED_AFTER}` days. This is `{percentage:.2%}` of the guild's population. Proceed?", + f"verified in `{constants.Verification.kicked_after}` days. This is `{percentage:.2%}` of the guild's " + f"population. Proceed?", allowed_mentions=mention_role(constants.Roles.core_developers), ) @@ -333,7 +322,7 @@ class Verification(Cog): Note that this is a potentially destructive operation. Returns the amount of successful requests. """ - log.info(f"Kicking {len(members)} members from the guild (not verified after {KICKED_AFTER} days)") + log.info(f"Kicking {len(members)} members (not verified after {constants.Verification.kicked_after} days)") async def kick_request(member: discord.Member) -> None: """Send `KICKED_MESSAGE` to `member` and kick them from the guild.""" @@ -343,7 +332,7 @@ class Verification(Cog): log.trace(f"DM dispatch failed on 403 error with code: {exc_403.code}") if exc_403.code != 50_007: # 403 raised for any other reason than disabled DMs raise StopExecution(reason=exc_403) - await member.kick(reason=f"User has not verified in {KICKED_AFTER} days") + await member.kick(reason=f"User has not verified in {constants.Verification.kicked_after} days") n_kicked = await self._send_requests(members, kick_request, Limit(batch_size=2, sleep_secs=1)) self.bot.stats.incr("verification.kicked", count=n_kicked) @@ -358,11 +347,14 @@ class Verification(Cog): Returns the amount of successful requests. """ - log.info(f"Assigning {role} role to {len(members)} members (not verified after {UNVERIFIED_AFTER} days)") + log.info( + f"Assigning {role} role to {len(members)} members (not verified " + f"after {constants.Verification.unverified_after} days)" + ) async def role_request(member: discord.Member) -> None: """Add `role` to `member`.""" - await member.add_roles(role, reason=f"User has not verified in {UNVERIFIED_AFTER} days") + await member.add_roles(role, reason=f"Not verified after {constants.Verification.unverified_after} days") return await self._send_requests(members, role_request, Limit(batch_size=25, sleep_secs=1)) @@ -397,10 +389,13 @@ class Verification(Cog): # to do with them based on time passed since their join date since_join = current_dt - member.joined_at - if since_join > timedelta(days=KICKED_AFTER): + if since_join > timedelta(days=constants.Verification.kicked_after): for_kick.add(member) # User should be removed from the guild - elif since_join > timedelta(days=UNVERIFIED_AFTER) and unverified not in member.roles: + elif ( + since_join > timedelta(days=constants.Verification.unverified_after) + and unverified not in member.roles + ): for_role.add(member) # User should be given the @Unverified role log.debug(f"Found {len(for_role)} users for {unverified} role, {len(for_kick)} users to be kicked") @@ -445,7 +440,7 @@ class Verification(Cog): # endregion # region: periodically ping @Unverified - @tasks.loop(hours=REMINDER_FREQUENCY) + @tasks.loop(hours=constants.Verification.reminder_frequency) async def ping_unverified(self) -> None: """ Delete latest `REMINDER_MESSAGE` and send it again. @@ -488,7 +483,7 @@ class Verification(Cog): time_since = datetime.utcnow() - snowflake_time(last_reminder) log.trace(f"Time since latest verification reminder: {time_since}") - to_sleep = timedelta(hours=REMINDER_FREQUENCY) - time_since + to_sleep = timedelta(hours=constants.Verification.reminder_frequency) - time_since log.trace(f"Time to sleep until next ping: {to_sleep}") # Delta can be negative if `REMINDER_FREQUENCY` has already passed @@ -519,7 +514,7 @@ class Verification(Cog): if message.author.bot: # They're a bot, delete their message after the delay. - await message.delete(delay=BOT_MESSAGE_DELETE_DELAY) + await message.delete(delay=constants.Verification.bot_message_delete_delay) return # if a user mentions a role or guild member diff --git a/bot/constants.py b/bot/constants.py index daef6c095..820828a19 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -589,6 +589,16 @@ class PythonNews(metaclass=YAMLGetter): webhook: int +class Verification(metaclass=YAMLGetter): + section = "verification" + + unverified_after: int + kicked_after: int + reminder_frequency: int + bot_message_delete_delay: int + kick_confirmation_threshold: float + + class Event(Enum): """ Event names. This does not include every event (for example, raw diff --git a/config-default.yml b/config-default.yml index a98fd14ef..c89695bd9 100644 --- a/config-default.yml +++ b/config-default.yml @@ -493,5 +493,18 @@ python_news: channel: *PYNEWS_CHANNEL webhook: *PYNEWS_WEBHOOK + +verification: + unverified_after: 3 # Days after which non-Developers receive the @Unverified role + kicked_after: 30 # Days after which non-Developers get kicked from the guild + reminder_frequency: 28 # Hours between @Unverified pings + bot_message_delete_delay: 10 # Seconds before deleting bots response in #verification + + # Number in range [0, 1] determining the percentage of unverified users that are safe + # to be kicked from the guild in one batch, any larger amount will require staff confirmation, + # set this to 0 to require explicit approval for batches of any size + kick_confirmation_threshold: 0.01 # 1% + + config: required_keys: ['bot.token'] -- cgit v1.2.3 From 7ebc481c32cf9b7f0ee1124b22e4ed8c68de9386 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 11 Sep 2020 19:46:48 +0200 Subject: Verification: update & improve docstrings After moving constants to config, the docstring references were not updated accordingly, and remained uppercase. This commit also removed the redundant list indentation. --- bot/cogs/verification.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 0092a0898..9ae92a228 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -111,12 +111,11 @@ class Verification(Cog): There are two internal tasks in this cog: - * `update_unverified_members` - * Unverified members are given the @Unverified role after `UNVERIFIED_AFTER` days - * Unverified members are kicked after `UNVERIFIED_AFTER` days - - * `ping_unverified` - * Periodically ping the @Unverified role in the verification channel + * `update_unverified_members` + * Unverified members are given the @Unverified role after configured `unverified_after` days + * Unverified members are kicked after configured `kicked_after` days + * `ping_unverified` + * Periodically ping the @Unverified role in the verification channel Statistics are collected in the 'verification.' namespace. @@ -188,8 +187,8 @@ class Verification(Cog): Determine whether `n_members` is a reasonable amount of members to kick. First, `n_members` is checked against the size of the PyDis guild. If `n_members` are - more than `KICK_CONFIRMATION_THRESHOLD` of the guild, the operation must be confirmed - by staff in #core-dev. Otherwise, the operation is seen as safe. + more than the configured `kick_confirmation_threshold` of the guild, the operation + must be confirmed by staff in #core-dev. Otherwise, the operation is seen as safe. """ log.debug(f"Checking whether {n_members} members are safe to kick") @@ -363,8 +362,8 @@ class Verification(Cog): Check in on the verification status of PyDis members. This coroutine finds two sets of users: - * Not verified after `UNVERIFIED_AFTER` days, should be given the @Unverified role - * Not verified after `KICKED_AFTER` days, should be kicked from the guild + * Not verified after configured `unverified_after` days, should be given the @Unverified role + * Not verified after configured `kicked_after` days, should be kicked from the guild These sets are always disjoint, i.e. share no common members. """ @@ -471,7 +470,7 @@ class Verification(Cog): Sleep until `REMINDER_MESSAGE` should be sent again. If latest reminder is not cached, exit instantly. Otherwise, wait wait until the - configured `REMINDER_FREQUENCY` has passed. + configured `reminder_frequency` has passed. """ last_reminder: t.Optional[int] = await self.task_cache.get("last_reminder") @@ -486,7 +485,7 @@ class Verification(Cog): to_sleep = timedelta(hours=constants.Verification.reminder_frequency) - time_since log.trace(f"Time to sleep until next ping: {to_sleep}") - # Delta can be negative if `REMINDER_FREQUENCY` has already passed + # Delta can be negative if `reminder_frequency` has already passed secs = max(to_sleep.total_seconds(), 0) await asyncio.sleep(secs) -- cgit v1.2.3 From c705d6d19506873ab6e21a7301963510833b2b07 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 13 Sep 2020 08:44:49 +0300 Subject: Shorten infraction text when any other field than reason is too long --- bot/cogs/moderation/utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 95820404a..4a3c14391 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -160,6 +160,10 @@ async def notify_infraction( reason=textwrap.shorten(reason, 1000, placeholder="...") if reason else "No reason provided." ) + # For case when other fields than reason is too long and this reach limit, then force-shorten string + if len(text) > 2048: + text = f"{text[:2045]}..." + embed = discord.Embed( description=text, colour=Colours.soft_red -- cgit v1.2.3 From 57786e90cab270f8526e03414d62f42fa249a593 Mon Sep 17 00:00:00 2001 From: rohanjnr Date: Tue, 15 Sep 2020 12:22:06 +0530 Subject: Restrict nsfw subreddit(s) or similar (subreddits that require you to be over 18). Changed the return format a little bit for the fetch_posts() function, instead of returning an empty list, it returns a list with a dict holding the error message. --- bot/cogs/reddit.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 5d9e2c20b..0b002f9b6 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -141,12 +141,27 @@ class Reddit(Cog): # Got appropriate response - process and return. content = await response.json() posts = content["data"]["children"] + if posts[0]["data"]["over_18"]: + resp_not_allowed = [ + { + "error": "Oops ! Looks like this subreddit, doesn't fit in the scope of the server." + } + ] + return resp_not_allowed return posts[:amount] await asyncio.sleep(3) log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}") - return list() # Failed to get appropriate response within allowed number of retries. + resp_failed = [ + { + "error": ( + "Sorry! We couldn't find any posts from that subreddit. " + "If this problem persists, please let us know." + ) + } + ] + return resp_failed # Failed to get appropriate response within allowed number of retries. async def get_top_posts(self, subreddit: Subreddit, time: str = "all", amount: int = 5) -> Embed: """ @@ -164,14 +179,10 @@ class Reddit(Cog): amount=amount, params={"t": time} ) - - if not posts: + if "error" in posts[0]: embed.title = random.choice(ERROR_REPLIES) embed.colour = Colour.red() - embed.description = ( - "Sorry! We couldn't find any posts from that subreddit. " - "If this problem persists, please let us know." - ) + embed.description = posts[0]["error"] return embed -- cgit v1.2.3 From 93145528d7859842602df5c6535f3995187ffadb Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Wed, 16 Sep 2020 17:34:12 +0200 Subject: Updating names of reddit emotes. --- config-default.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/config-default.yml b/config-default.yml index 20254d584..e97ebf0e8 100644 --- a/config-default.yml +++ b/config-default.yml @@ -76,9 +76,10 @@ style: ducky_maul: &DUCKY_MAUL 640137724958867467 ducky_santa: &DUCKY_SANTA 655360331002019870 - upvotes: "<:upvotes:638729835245731840>" - comments: "<:comments:638729835073765387>" - user: "<:user:638729835442602003>" + # emotes used for #reddit + upvotes: "<:reddit_upvotes:638729835245731840>" + comments: "<:reddit_comments:638729835073765387>" + user: "<:reddit_user:638729835442602003>" icons: crown_blurple: "https://cdn.discordapp.com/emojis/469964153289965568.png" -- cgit v1.2.3 From f8272b4cdcab08145c1cecedd8bbbfea4bbf3239 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Wed, 16 Sep 2020 19:42:10 +0200 Subject: update the reddit emojis to actual emojis' --- config-default.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config-default.yml b/config-default.yml index e97ebf0e8..87bfd1378 100644 --- a/config-default.yml +++ b/config-default.yml @@ -77,9 +77,9 @@ style: ducky_santa: &DUCKY_SANTA 655360331002019870 # emotes used for #reddit - upvotes: "<:reddit_upvotes:638729835245731840>" - comments: "<:reddit_comments:638729835073765387>" - user: "<:reddit_user:638729835442602003>" + upvotes: "<:reddit_upvotes:755845219890757644> " + comments: "<:reddit_comments:755845255001014384>" + user: "<:reddit_users:755845303822974997>" icons: crown_blurple: "https://cdn.discordapp.com/emojis/469964153289965568.png" -- cgit v1.2.3 From 6cef33ee7b68d07a1026b784f0350b146469702d Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Wed, 16 Sep 2020 19:43:44 +0200 Subject: remove random space in `upvotes` value --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index 87bfd1378..58651f548 100644 --- a/config-default.yml +++ b/config-default.yml @@ -77,7 +77,7 @@ style: ducky_santa: &DUCKY_SANTA 655360331002019870 # emotes used for #reddit - upvotes: "<:reddit_upvotes:755845219890757644> " + upvotes: "<:reddit_upvotes:755845219890757644>" comments: "<:reddit_comments:755845255001014384>" user: "<:reddit_users:755845303822974997>" -- cgit v1.2.3 From 201efc924fb66d14592adaa229b87740043e13e2 Mon Sep 17 00:00:00 2001 From: Bast Date: Sat, 19 Sep 2020 12:15:22 -0700 Subject: Add feature to token_remover: log detected user ID, and ping if it's a user in the server Updated tests This comes with a change that a user ID must actually be able to be decoded into an integer to be considered a valid token --- bot/cogs/token_remover.py | 64 ++++++++++++++++++++++++++++-------- tests/bot/cogs/test_token_remover.py | 45 +++++++++++++++++++++---- 2 files changed, 90 insertions(+), 19 deletions(-) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index ef979f222..93ceda6be 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -18,6 +18,11 @@ LOG_MESSAGE = ( "Censored a seemingly valid token sent by {author} (`{author_id}`) in {channel}, " "token was `{user_id}.{timestamp}.{hmac}`" ) +DECODED_LOG_MESSAGE = "The token user_id decodes into {user_id}." +USER_TOKEN_MESSAGE = ( + "The token user_id decodes into {user_id}, " + "which matches `{user_name}` and means this is a valid USER token." +) DELETION_MESSAGE_TEMPLATE = ( "Hey {mention}! I noticed you posted a seemingly valid Discord API " "token in your message and have removed your message. " @@ -92,7 +97,14 @@ class TokenRemover(Cog): await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention)) - log_message = self.format_log_message(msg, found_token) + user_name = None + user_id = self.extract_user_id(found_token.user_id) + user = msg.guild.get_member(user_id) + + if user: + user_name = str(user) + + log_message = self.format_log_message(msg, found_token, user_id, user_name) log.debug(log_message) # Send pretty mod log embed to mod-alerts @@ -103,14 +115,24 @@ class TokenRemover(Cog): text=log_message, thumbnail=msg.author.avatar_url_as(static_format="png"), channel_id=Channels.mod_alerts, + ping_everyone=user_name is not None, ) self.bot.stats.incr("tokens.removed_tokens") @staticmethod - def format_log_message(msg: Message, token: Token) -> str: - """Return the log message to send for `token` being censored in `msg`.""" - return LOG_MESSAGE.format( + def format_log_message( + msg: Message, + token: Token, + user_id: int, + user_name: t.Optional[str] = None, + ) -> str: + """ + Return the log message to send for `token` being censored in `msg`. + + Additonally, mention if the token was decodable into a user id, and if that resolves to a user on the server. + """ + message = LOG_MESSAGE.format( author=msg.author, author_id=msg.author.id, channel=msg.channel.mention, @@ -118,6 +140,11 @@ class TokenRemover(Cog): timestamp=token.timestamp, hmac='x' * len(token.hmac), ) + if user_name: + more = USER_TOKEN_MESSAGE.format(user_id=user_id, user_name=user_name) + else: + more = DECODED_LOG_MESSAGE.format(user_id=user_id) + return message + "\n" + more @classmethod def find_token_in_message(cls, msg: Message) -> t.Optional[Token]: @@ -134,23 +161,34 @@ class TokenRemover(Cog): return @staticmethod - def is_valid_user_id(b64_content: str) -> bool: - """ - Check potential token to see if it contains a valid Discord user ID. - - See: https://discordapp.com/developers/docs/reference#snowflakes - """ + def extract_user_id(b64_content: str) -> t.Optional[int]: + """Return a userid integer from part of a potential token, or None if it couldn't be decoded.""" b64_content = utils.pad_base64(b64_content) try: decoded_bytes = base64.urlsafe_b64decode(b64_content) string = decoded_bytes.decode('utf-8') - - # isdigit on its own would match a lot of other Unicode characters, hence the isascii. - return string.isascii() and string.isdigit() + if not (string.isascii() and string.isdigit()): + # This case triggers if there are fancy unicode digits in the base64 encoding, + # that means it's not a valid user id. + return None + return int(string) except (binascii.Error, ValueError): + return None + + @classmethod + def is_valid_user_id(cls, b64_content: str) -> bool: + """ + Check potential token to see if it contains a valid Discord user ID. + + See: https://discordapp.com/developers/docs/reference#snowflakes + """ + decoded_id = cls.extract_user_id(b64_content) + if not decoded_id: return False + return True + @staticmethod def is_valid_timestamp(b64_content: str) -> bool: """ diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 3349caa73..275350144 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -22,6 +22,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.msg = MockMessage(id=555, content="hello world") self.msg.channel.mention = "#lemonade-stand" + self.msg.guild.get_member = MagicMock(return_value="Bob") self.msg.author.__str__ = MagicMock(return_value=self.msg.author.name) self.msg.author.avatar_url_as.return_value = "picture-lemon.png" @@ -230,15 +231,41 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): results = [match[0] for match in results] self.assertCountEqual((token_1, token_2), results) - @autospec("bot.cogs.token_remover", "LOG_MESSAGE") - def test_format_log_message(self, log_message): + @autospec("bot.cogs.token_remover", "LOG_MESSAGE", "DECODED_LOG_MESSAGE") + def test_format_log_message(self, log_message, decoded_log_message): + """Should correctly format the log message with info from the message and token.""" + token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") + log_message.format.return_value = "Howdy" + decoded_log_message.format.return_value = " Partner" + + return_value = TokenRemover.format_log_message(self.msg, token, 472265943062413332, None) + + self.assertEqual( + return_value, + log_message.format.return_value + "\n" + decoded_log_message.format.return_value, + ) + log_message.format.assert_called_once_with( + author=self.msg.author, + author_id=self.msg.author.id, + channel=self.msg.channel.mention, + user_id=token.user_id, + timestamp=token.timestamp, + hmac="x" * len(token.hmac), + ) + + @autospec("bot.cogs.token_remover", "LOG_MESSAGE", "USER_TOKEN_MESSAGE") + def test_format_log_message_user_token(self, log_message, user_token_message): """Should correctly format the log message with info from the message and token.""" token = Token("NDY3MjIzMjMwNjUwNzc3NjQx", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") log_message.format.return_value = "Howdy" + user_token_message.format.return_value = "Partner" - return_value = TokenRemover.format_log_message(self.msg, token) + return_value = TokenRemover.format_log_message(self.msg, token, 467223230650777641, "Bob") - self.assertEqual(return_value, log_message.format.return_value) + self.assertEqual( + return_value, + log_message.format.return_value + "\n" + user_token_message.format.return_value, + ) log_message.format.assert_called_once_with( author=self.msg.author, author_id=self.msg.author.id, @@ -247,6 +274,10 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): timestamp=token.timestamp, hmac="x" * len(token.hmac), ) + user_token_message.format.assert_called_once_with( + user_id=467223230650777641, + user_name="Bob", + ) @mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock) @autospec("bot.cogs.token_remover", "log") @@ -256,6 +287,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): cog = TokenRemover(self.bot) mod_log = mock.create_autospec(ModLog, spec_set=True, instance=True) token = mock.create_autospec(Token, spec_set=True, instance=True) + token.user_id = "no-id" log_msg = "testing123" mod_log_property.return_value = mod_log @@ -268,7 +300,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): token_remover.DELETION_MESSAGE_TEMPLATE.format(mention=self.msg.author.mention) ) - format_log_message.assert_called_once_with(self.msg, token) + format_log_message.assert_called_once_with(self.msg, token, None, "Bob") logger.debug.assert_called_with(log_msg) self.bot.stats.incr.assert_called_once_with("tokens.removed_tokens") @@ -279,7 +311,8 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): title="Token removed!", text=log_msg, thumbnail=self.msg.author.avatar_url_as.return_value, - channel_id=constants.Channels.mod_alerts + channel_id=constants.Channels.mod_alerts, + ping_everyone=True, ) @mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock) -- cgit v1.2.3 From 83e17627d3fa4e0eb135b5039decd02eaf3d060c Mon Sep 17 00:00:00 2001 From: Bast Date: Sat, 19 Sep 2020 12:16:40 -0700 Subject: Make token_remover check basic HMAC validity (not low entropy) Handles cases like xxx.xxxxx.xxxxxxxx where a user has intentionally censored part of a token, and will not consider them "valid" --- bot/cogs/token_remover.py | 21 ++++++++++++++++++- tests/bot/cogs/test_token_remover.py | 40 +++++++++++++++++++++++++++++++----- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 93ceda6be..17778b415 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -1,5 +1,6 @@ import base64 import binascii +import collections import logging import re import typing as t @@ -153,7 +154,9 @@ class TokenRemover(Cog): # token check (e.g. `message.channel.send` also matches our token pattern) for match in TOKEN_RE.finditer(msg.content): token = Token(*match.groups()) - if cls.is_valid_user_id(token.user_id) and cls.is_valid_timestamp(token.timestamp): + if cls.is_valid_user_id(token.user_id) \ + and cls.is_valid_timestamp(token.timestamp) \ + and cls.is_maybevalid_hmac(token.hmac): # Short-circuit on first match return token @@ -214,6 +217,22 @@ class TokenRemover(Cog): log.debug(f"Invalid token timestamp '{b64_content}': smaller than Discord epoch") return False + @staticmethod + def is_maybevalid_hmac(b64_content: str) -> bool: + """ + Determine if a given hmac portion of a token is potentially valid. + + If the HMAC has 3 or less characters, it's probably a dummy value like "xxxxxxxxxx", + and thus the token can probably be skipped. + """ + unique = len(collections.Counter(b64_content.lower()).keys()) + if unique <= 3: + log.debug(f"Considering the hmac {b64_content} a dummy because it has {unique}" + " case-insensitively unique characters") + return False + else: + return True + def setup(bot: Bot) -> None: """Load the TokenRemover cog.""" diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 275350144..56d269105 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -86,6 +86,34 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): result = TokenRemover.is_valid_timestamp(timestamp) self.assertFalse(result) + def test_is_valid_hmac_valid(self): + """Should consider hmac valid if it is a valid hmac with a variety of characters.""" + valid_hmacs = ( + "VXmErH7j511turNpfURmb0rVNm8", + "Ysnu2wacjaKs7qnoo46S8Dm2us8", + "sJf6omBPORBPju3WJEIAcwW9Zds", + "s45jqDV_Iisn-symw0yDRrk_jf4", + ) + + for hmac in valid_hmacs: + with self.subTest(msg=hmac): + result = TokenRemover.is_maybevalid_hmac(hmac) + self.assertTrue(result) + + def test_is_invalid_hmac_invalid(self): + """Should consider hmac invalid if it possesses too little variety.""" + invalid_hmacs = ( + ("xxxxxxxxxxxxxxxxxx", "Single character"), + ("XxXxXxXxXxXxXxXxXx", "Single character alternating case"), + ("ASFasfASFasfASFASsf", "Three characters alternating-case"), + ("asdasdasdasdasdasdasd", "Three characters one case"), + ) + + for hmac, msg in invalid_hmacs: + with self.subTest(msg=msg): + result = TokenRemover.is_maybevalid_hmac(hmac) + self.assertFalse(result) + def test_mod_log_property(self): """The `mod_log` property should ask the bot to return the `ModLog` cog.""" self.bot.get_cog.return_value = 'lemon' @@ -143,11 +171,11 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(return_value) token_re.finditer.assert_called_once_with(self.msg.content) - @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") + @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp", "is_maybevalid_hmac") @autospec("bot.cogs.token_remover", "Token") @autospec("bot.cogs.token_remover", "TOKEN_RE") - def test_find_token_valid_match(self, token_re, token_cls, is_valid_id, is_valid_timestamp): - """The first match with a valid user ID and timestamp should be returned as a `Token`.""" + def test_find_token_valid_match(self, token_re, token_cls, is_valid_id, is_valid_timestamp, is_maybevalid_hmac): + """The first match with a valid user ID. timestamp and hmac should be returned as a `Token`.""" matches = [ mock.create_autospec(Match, spec_set=True, instance=True), mock.create_autospec(Match, spec_set=True, instance=True), @@ -161,21 +189,23 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): token_cls.side_effect = tokens is_valid_id.side_effect = (False, True) # The 1st match will be invalid, 2nd one valid. is_valid_timestamp.return_value = True + is_maybevalid_hmac.return_value = True return_value = TokenRemover.find_token_in_message(self.msg) self.assertEqual(tokens[1], return_value) token_re.finditer.assert_called_once_with(self.msg.content) - @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") + @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp", "is_maybevalid_hmac") @autospec("bot.cogs.token_remover", "Token") @autospec("bot.cogs.token_remover", "TOKEN_RE") - def test_find_token_invalid_matches(self, token_re, token_cls, is_valid_id, is_valid_timestamp): + def test_find_token_invalid_matches(self, token_re, token_cls, is_valid_id, is_valid_timestamp, is_maybevalid_hmac): """None should be returned if no matches have valid user IDs or timestamps.""" token_re.finditer.return_value = [mock.create_autospec(Match, spec_set=True, instance=True)] token_cls.return_value = mock.create_autospec(Token, spec_set=True, instance=True) is_valid_id.return_value = False is_valid_timestamp.return_value = False + is_maybevalid_hmac.return_value = False return_value = TokenRemover.find_token_in_message(self.msg) -- cgit v1.2.3 From fc058490d62fa373e6ebb01392c511027401e72f Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sat, 19 Sep 2020 21:45:54 +0200 Subject: Use async-rediscache package for our redis caches I've migrated our redis caches over to the async-rediscache package that we've recently released (https://git.pydis.com/async-rediscache). The main functionality remains the same, although the package handles some things, like getting the active session, differently internally. The main changes you'll note for our bot are: - We create a RedisSession instance and ensure that it connects before we even create a Bot instance in `__main__.py`. - We are now actually using a connection pool instead of a single connection. - Our Bot subclass now has a new required kwarg: `redis_session`. - Bool values are now properly converted to and from typestrings. In addition, I've made sure that our MockBot passes a MagicMock for the new `redis_session` kwarg when creating a Bot instance for the spec_set. Signed-off-by: Sebastiaan Zeeff --- Pipfile | 2 +- Pipfile.lock | 243 ++++++++++++++++++++++++---------------------- bot/__main__.py | 20 ++++ bot/bot.py | 49 ++-------- bot/cogs/dm_relay.py | 2 +- bot/cogs/filtering.py | 4 +- bot/cogs/help_channels.py | 2 +- bot/cogs/verification.py | 2 +- tests/helpers.py | 6 +- 9 files changed, 167 insertions(+), 163 deletions(-) diff --git a/Pipfile b/Pipfile index 6fff2223e..8ab5d230e 100644 --- a/Pipfile +++ b/Pipfile @@ -8,12 +8,12 @@ aio-pika = "~=6.1" aiodns = "~=2.0" aiohttp = "~=3.5" aioredis = "~=1.3.1" +"async-rediscache[fakeredis]" = "~=0.1.2" beautifulsoup4 = "~=4.9" colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"} coloredlogs = "~=14.0" deepdiff = "~=4.0" discord.py = "~=1.4.0" -fakeredis = "~=1.4" feedparser = "~=5.2" fuzzywuzzy = "~=0.17" lxml = "~=4.4" diff --git a/Pipfile.lock b/Pipfile.lock index 50ddd478c..97436413d 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "1905fd7eb15074ddbf04f2177b6cdd65edc4c74cb5fcbf4e6ca08ef649ba8a3c" + "sha256": "63eec3557c8bfd42191cb5a7525d6e298471c16863adff485d917e08b72cd787" }, "pipfile-spec": 6, "requires": { @@ -18,11 +18,11 @@ "default": { "aio-pika": { "hashes": [ - "sha256:c4cbbeb85b3c7bf81bc127371846cd949e6231717ce1e6ac7ee1dd5ede21f866", - "sha256:ec7fef24f588d90314873463ab4f2c3debce0bd8830e49e3786586be96bc2e8e" + "sha256:4a20d4d941e1f113a950ea529a90bd9159c8d7aafaa1c71e9c707c8c2b526ea6", + "sha256:7bf3f183df1eb348d007210a0c1a3c5c755f1b3def1a9a395e93f30b91da1daf" ], "index": "pypi", - "version": "==6.6.1" + "version": "==6.7.0" }, "aiodns": { "hashes": [ @@ -73,6 +73,18 @@ ], "version": "==0.7.12" }, + "async-rediscache": { + "extras": [ + "fakeredis" + ], + "hashes": [ + "sha256:407aed1aad97bf22f690eca5369806d22eefc8ca104a52c1f1bd47dd6db45fc2", + "sha256:563aaff79ec611a92a0ad78e39ff159e3a4b4cf0bea41e061de5f3701a17d50c" + ], + "index": "pypi", + "markers": "python_version ~= '3.7'", + "version": "==0.1.2" + }, "async-timeout": { "hashes": [ "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", @@ -83,11 +95,11 @@ }, "attrs": { "hashes": [ - "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", - "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" + "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594", + "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==19.3.0" + "version": "==20.2.0" }, "babel": { "hashes": [ @@ -115,36 +127,36 @@ }, "cffi": { "hashes": [ - "sha256:267adcf6e68d77ba154334a3e4fc921b8e63cbb38ca00d33d40655d4228502bc", - "sha256:26f33e8f6a70c255767e3c3f957ccafc7f1f706b966e110b855bfe944511f1f9", - "sha256:3cd2c044517f38d1b577f05927fb9729d3396f1d44d0c659a445599e79519792", - "sha256:4a03416915b82b81af5502459a8a9dd62a3c299b295dcdf470877cb948d655f2", - "sha256:4ce1e995aeecf7cc32380bc11598bfdfa017d592259d5da00fc7ded11e61d022", - "sha256:4f53e4128c81ca3212ff4cf097c797ab44646a40b42ec02a891155cd7a2ba4d8", - "sha256:4fa72a52a906425416f41738728268072d5acfd48cbe7796af07a923236bcf96", - "sha256:66dd45eb9530e3dde8f7c009f84568bc7cac489b93d04ac86e3111fb46e470c2", - "sha256:6923d077d9ae9e8bacbdb1c07ae78405a9306c8fd1af13bfa06ca891095eb995", - "sha256:833401b15de1bb92791d7b6fb353d4af60dc688eaa521bd97203dcd2d124a7c1", - "sha256:8416ed88ddc057bab0526d4e4e9f3660f614ac2394b5e019a628cdfff3733849", - "sha256:892daa86384994fdf4856cb43c93f40cbe80f7f95bb5da94971b39c7f54b3a9c", - "sha256:98be759efdb5e5fa161e46d404f4e0ce388e72fbf7d9baf010aff16689e22abe", - "sha256:a6d28e7f14ecf3b2ad67c4f106841218c8ab12a0683b1528534a6c87d2307af3", - "sha256:b1d6ebc891607e71fd9da71688fcf332a6630b7f5b7f5549e6e631821c0e5d90", - "sha256:b2a2b0d276a136146e012154baefaea2758ef1f56ae9f4e01c612b0831e0bd2f", - "sha256:b87dfa9f10a470eee7f24234a37d1d5f51e5f5fa9eeffda7c282e2b8f5162eb1", - "sha256:bac0d6f7728a9cc3c1e06d4fcbac12aaa70e9379b3025b27ec1226f0e2d404cf", - "sha256:c991112622baee0ae4d55c008380c32ecfd0ad417bcd0417ba432e6ba7328caa", - "sha256:cda422d54ee7905bfc53ee6915ab68fe7b230cacf581110df4272ee10462aadc", - "sha256:d3148b6ba3923c5850ea197a91a42683f946dba7e8eb82dfa211ab7e708de939", - "sha256:d6033b4ffa34ef70f0b8086fd4c3df4bf801fee485a8a7d4519399818351aa8e", - "sha256:ddff0b2bd7edcc8c82d1adde6dbbf5e60d57ce985402541cd2985c27f7bec2a0", - "sha256:e23cb7f1d8e0f93addf0cae3c5b6f00324cccb4a7949ee558d7b6ca973ab8ae9", - "sha256:effd2ba52cee4ceff1a77f20d2a9f9bf8d50353c854a282b8760ac15b9833168", - "sha256:f90c2267101010de42f7273c94a1f026e56cbc043f9330acd8a80e64300aba33", - "sha256:f960375e9823ae6a07072ff7f8a85954e5a6434f97869f50d0e41649a1c8144f", - "sha256:fcf32bf76dc25e30ed793145a57426064520890d7c02866eb93d3e4abe516948" - ], - "version": "==1.14.1" + "sha256:0da50dcbccd7cb7e6c741ab7912b2eff48e85af217d72b57f80ebc616257125e", + "sha256:12a453e03124069b6896107ee133ae3ab04c624bb10683e1ed1c1663df17c13c", + "sha256:15419020b0e812b40d96ec9d369b2bc8109cc3295eac6e013d3261343580cc7e", + "sha256:15a5f59a4808f82d8ec7364cbace851df591c2d43bc76bcbe5c4543a7ddd1bf1", + "sha256:23e44937d7695c27c66a54d793dd4b45889a81b35c0751ba91040fe825ec59c4", + "sha256:29c4688ace466a365b85a51dcc5e3c853c1d283f293dfcc12f7a77e498f160d2", + "sha256:57214fa5430399dffd54f4be37b56fe22cedb2b98862550d43cc085fb698dc2c", + "sha256:577791f948d34d569acb2d1add5831731c59d5a0c50a6d9f629ae1cefd9ca4a0", + "sha256:6539314d84c4d36f28d73adc1b45e9f4ee2a89cdc7e5d2b0a6dbacba31906798", + "sha256:65867d63f0fd1b500fa343d7798fa64e9e681b594e0a07dc934c13e76ee28fb1", + "sha256:672b539db20fef6b03d6f7a14b5825d57c98e4026401fce838849f8de73fe4d4", + "sha256:6843db0343e12e3f52cc58430ad559d850a53684f5b352540ca3f1bc56df0731", + "sha256:7057613efefd36cacabbdbcef010e0a9c20a88fc07eb3e616019ea1692fa5df4", + "sha256:76ada88d62eb24de7051c5157a1a78fd853cca9b91c0713c2e973e4196271d0c", + "sha256:837398c2ec00228679513802e3744d1e8e3cb1204aa6ad408b6aff081e99a487", + "sha256:8662aabfeab00cea149a3d1c2999b0731e70c6b5bac596d95d13f643e76d3d4e", + "sha256:95e9094162fa712f18b4f60896e34b621df99147c2cee216cfa8f022294e8e9f", + "sha256:99cc66b33c418cd579c0f03b77b94263c305c389cb0c6972dac420f24b3bf123", + "sha256:9b219511d8b64d3fa14261963933be34028ea0e57455baf6781fe399c2c3206c", + "sha256:ae8f34d50af2c2154035984b8b5fc5d9ed63f32fe615646ab435b05b132ca91b", + "sha256:b9aa9d8818c2e917fa2c105ad538e222a5bce59777133840b93134022a7ce650", + "sha256:bf44a9a0141a082e89c90e8d785b212a872db793a0080c20f6ae6e2a0ebf82ad", + "sha256:c0b48b98d79cf795b0916c57bebbc6d16bb43b9fc9b8c9f57f4cf05881904c75", + "sha256:da9d3c506f43e220336433dffe643fbfa40096d408cb9b7f2477892f369d5f82", + "sha256:e4082d832e36e7f9b2278bc774886ca8207346b99f278e54c9de4834f17232f7", + "sha256:e4b9b7af398c32e408c00eb4e0d33ced2f9121fd9fb978e6c1b57edd014a7d15", + "sha256:e613514a82539fc48291d01933951a13ae93b6b444a88782480be32245ed4afa", + "sha256:f5033952def24172e60493b68717792e3aebb387a8d186c43c020d9363ee7281" + ], + "version": "==1.14.2" }, "chardet": { "hashes": [ @@ -188,11 +200,11 @@ }, "discord.py": { "hashes": [ - "sha256:2b1846bfa382b54f4eace8e437a9f59f185388c5b08749ac0e1bbd98e05bfde5", - "sha256:f3db9531fccc391f51de65cfa46133106a9ba12ff2927aca6c14bffd3b7f17b5" + "sha256:98ea3096a3585c9c379209926f530808f5fcf4930928d8cfb579d2562d119570", + "sha256:f9decb3bfa94613d922376288617e6a6f969260923643e2897f4540c34793442" ], "markers": "python_full_version >= '3.5.3'", - "version": "==1.4.0" + "version": "==1.4.1" }, "docutils": { "hashes": [ @@ -204,11 +216,10 @@ }, "fakeredis": { "hashes": [ - "sha256:790c85ad0f3b2967aba1f51767021bc59760fcb612159584be018ea7384f7fd2", - "sha256:fdfe06f277092d022c271fcaefdc1f0c8d9bfa8cb15374cae41d66a20bd96d2b" + "sha256:7ea0866ba5edb40fe2e9b1722535df0c7e6b91d518aa5f50d96c2fff3ea7f4c2", + "sha256:aad8836ffe0319ffbba66dcf872ac6e7e32d1f19790e31296ba58445efb0a5c7" ], - "index": "pypi", - "version": "==1.4.2" + "version": "==1.4.3" }, "feedparser": { "hashes": [ @@ -350,10 +361,11 @@ }, "markdownify": { "hashes": [ - "sha256:28ce67d1888e4908faaab7b04d2193cda70ea4f902f156a21d0aaea55e63e0a1" + "sha256:30be8340724e706c9e811c27fe8c1542cf74a15b46827924fff5c54b40dd9b0d", + "sha256:a69588194fd76634f0139d6801b820fd652dc5eeba9530e90d323dfdc0155252" ], "index": "pypi", - "version": "==0.4.1" + "version": "==0.5.3" }, "markupsafe": { "hashes": [ @@ -396,11 +408,11 @@ }, "more-itertools": { "hashes": [ - "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5", - "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2" + "sha256:6f83822ae94818eae2612063a5101a7311e68ae8002005b5e05f03fd74a86a20", + "sha256:9b30f12df9393f0d28af9210ff8efe48d10c94f73e5daf886f10c4b0b0b4f03c" ], "index": "pypi", - "version": "==8.4.0" + "version": "==8.5.0" }, "multidict": { "hashes": [ @@ -491,11 +503,11 @@ }, "pygments": { "hashes": [ - "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44", - "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324" + "sha256:307543fe65c0947b126e83dd5a61bd8acbd84abec11f43caebaf5534cbc17998", + "sha256:926c3f319eda178d1bd90851e4317e6d8cdb5e292a3386aac9bd75eca29cf9c7" ], "markers": "python_version >= '3.5'", - "version": "==2.6.1" + "version": "==2.7.1" }, "pyparsing": { "hashes": [ @@ -555,11 +567,11 @@ }, "sentry-sdk": { "hashes": [ - "sha256:21b17d6aa064c0fb703a7c00f77cf6c9c497cf2f83345c28892980a5e742d116", - "sha256:4fc97114c77d005467b9b1a29f042e2bc01923cb683b0ef0bbda46e79fa12532" + "sha256:1a086486ff9da15791f294f6e9915eb3747d161ef64dee2d038a4d0b4a369b24", + "sha256:45486deb031cea6bbb25a540d7adb4dd48cd8a1cc31e6a5ce9fb4f792a572e9a" ], "index": "pypi", - "version": "==0.16.3" + "version": "==0.17.6" }, "six": { "hashes": [ @@ -697,11 +709,11 @@ }, "attrs": { "hashes": [ - "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", - "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" + "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594", + "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==19.3.0" + "version": "==20.2.0" }, "cfgv": { "hashes": [ @@ -713,43 +725,43 @@ }, "coverage": { "hashes": [ - "sha256:098a703d913be6fbd146a8c50cc76513d726b022d170e5e98dc56d958fd592fb", - "sha256:16042dc7f8e632e0dcd5206a5095ebd18cb1d005f4c89694f7f8aafd96dd43a3", - "sha256:1adb6be0dcef0cf9434619d3b892772fdb48e793300f9d762e480e043bd8e716", - "sha256:27ca5a2bc04d68f0776f2cdcb8bbd508bbe430a7bf9c02315cd05fb1d86d0034", - "sha256:28f42dc5172ebdc32622a2c3f7ead1b836cdbf253569ae5673f499e35db0bac3", - "sha256:2fcc8b58953d74d199a1a4d633df8146f0ac36c4e720b4a1997e9b6327af43a8", - "sha256:304fbe451698373dc6653772c72c5d5e883a4aadaf20343592a7abb2e643dae0", - "sha256:30bc103587e0d3df9e52cd9da1dd915265a22fad0b72afe54daf840c984b564f", - "sha256:40f70f81be4d34f8d491e55936904db5c527b0711b2a46513641a5729783c2e4", - "sha256:4186fc95c9febeab5681bc3248553d5ec8c2999b8424d4fc3a39c9cba5796962", - "sha256:46794c815e56f1431c66d81943fa90721bb858375fb36e5903697d5eef88627d", - "sha256:4869ab1c1ed33953bb2433ce7b894a28d724b7aa76c19b11e2878034a4e4680b", - "sha256:4f6428b55d2916a69f8d6453e48a505c07b2245653b0aa9f0dee38785939f5e4", - "sha256:52f185ffd3291196dc1aae506b42e178a592b0b60a8610b108e6ad892cfc1bb3", - "sha256:538f2fd5eb64366f37c97fdb3077d665fa946d2b6d95447622292f38407f9258", - "sha256:64c4f340338c68c463f1b56e3f2f0423f7b17ba6c3febae80b81f0e093077f59", - "sha256:675192fca634f0df69af3493a48224f211f8db4e84452b08d5fcebb9167adb01", - "sha256:700997b77cfab016533b3e7dbc03b71d33ee4df1d79f2463a318ca0263fc29dd", - "sha256:8505e614c983834239f865da2dd336dcf9d72776b951d5dfa5ac36b987726e1b", - "sha256:962c44070c281d86398aeb8f64e1bf37816a4dfc6f4c0f114756b14fc575621d", - "sha256:9e536783a5acee79a9b308be97d3952b662748c4037b6a24cbb339dc7ed8eb89", - "sha256:9ea749fd447ce7fb1ac71f7616371f04054d969d412d37611716721931e36efd", - "sha256:a34cb28e0747ea15e82d13e14de606747e9e484fb28d63c999483f5d5188e89b", - "sha256:a3ee9c793ffefe2944d3a2bd928a0e436cd0ac2d9e3723152d6fd5398838ce7d", - "sha256:aab75d99f3f2874733946a7648ce87a50019eb90baef931698f96b76b6769a46", - "sha256:b1ed2bdb27b4c9fc87058a1cb751c4df8752002143ed393899edb82b131e0546", - "sha256:b360d8fd88d2bad01cb953d81fd2edd4be539df7bfec41e8753fe9f4456a5082", - "sha256:b8f58c7db64d8f27078cbf2a4391af6aa4e4767cc08b37555c4ae064b8558d9b", - "sha256:c1bbb628ed5192124889b51204de27c575b3ffc05a5a91307e7640eff1d48da4", - "sha256:c2ff24df02a125b7b346c4c9078c8936da06964cc2d276292c357d64378158f8", - "sha256:c890728a93fffd0407d7d37c1e6083ff3f9f211c83b4316fae3778417eab9811", - "sha256:c96472b8ca5dc135fb0aa62f79b033f02aa434fb03a8b190600a5ae4102df1fd", - "sha256:ce7866f29d3025b5b34c2e944e66ebef0d92e4a4f2463f7266daa03a1332a651", - "sha256:e26c993bd4b220429d4ec8c1468eca445a4064a61c74ca08da7429af9bc53bb0" - ], - "index": "pypi", - "version": "==5.2.1" + "sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516", + "sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259", + "sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9", + "sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097", + "sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0", + "sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f", + "sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7", + "sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c", + "sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5", + "sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7", + "sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729", + "sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978", + "sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9", + "sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f", + "sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9", + "sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822", + "sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418", + "sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82", + "sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f", + "sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d", + "sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221", + "sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4", + "sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21", + "sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709", + "sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54", + "sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d", + "sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270", + "sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24", + "sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751", + "sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a", + "sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237", + "sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7", + "sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636", + "sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8" + ], + "index": "pypi", + "version": "==5.3" }, "distlib": { "hashes": [ @@ -775,11 +787,11 @@ }, "flake8-annotations": { "hashes": [ - "sha256:7816a5d8f65ffdf37b8e21e5b17e0fd1e492aa92638573276de066e889a22b26", - "sha256:8d18db74a750dd97f40b483cc3ef80d07d03f687525bad8fd83365dcd3bfd414" + "sha256:09fe1aa3f40cb8fef632a0ab3614050a7584bb884b6134e70cf1fc9eeee642fa", + "sha256:5bda552f074fd6e34276c7761756fa07d824ffac91ce9c0a8555eb2bc5b92d7a" ], "index": "pypi", - "version": "==2.3.0" + "version": "==2.4.0" }, "flake8-bugbear": { "hashes": [ @@ -837,11 +849,11 @@ }, "identify": { "hashes": [ - "sha256:110ed090fec6bce1aabe3c72d9258a9de82207adeaa5a05cd75c635880312f9a", - "sha256:ccd88716b890ecbe10920659450a635d2d25de499b9a638525a48b48261d989b" + "sha256:c770074ae1f19e08aadbda1c886bc6d0cb55ffdc503a8c0fe8699af2fc9664ae", + "sha256:d02d004568c5a01261839a05e91705e3e9f5c57a3551648f9b3fb2b9c62c0f62" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.4.25" + "version": "==1.5.3" }, "mccabe": { "hashes": [ @@ -852,9 +864,10 @@ }, "nodeenv": { "hashes": [ - "sha256:4b0b77afa3ba9b54f4b6396e60b0c83f59eaeb2d63dc3cc7a70f7f4af96c82bc" + "sha256:5304d424c529c997bc888453aeaa6362d242b6b4631e90f3d4bf1b290f1c84a9", + "sha256:ab45090ae383b716c4ef89e690c41ff8c2b257b85b309f01f3654df3d084bd7c" ], - "version": "==1.4.0" + "version": "==1.5.0" }, "pep8-naming": { "hashes": [ @@ -866,11 +879,11 @@ }, "pre-commit": { "hashes": [ - "sha256:1657663fdd63a321a4a739915d7d03baedd555b25054449090f97bb0cb30a915", - "sha256:e8b1315c585052e729ab7e99dcca5698266bedce9067d21dc909c23e3ceed626" + "sha256:810aef2a2ba4f31eed1941fc270e72696a1ad5590b9751839c90807d0fff6b9a", + "sha256:c54fd3e574565fe128ecc5e7d2f91279772ddb03f8729645fa812fe809084a70" ], "index": "pypi", - "version": "==2.6.0" + "version": "==2.7.1" }, "pycodestyle": { "hashes": [ @@ -882,11 +895,11 @@ }, "pydocstyle": { "hashes": [ - "sha256:da7831660b7355307b32778c4a0dbfb137d89254ef31a2b2978f50fc0b4d7586", - "sha256:f4f5d210610c2d153fae39093d44224c17429e2ad7da12a8b419aba5c2f614b5" + "sha256:19b86fa8617ed916776a11cd8bc0197e5b9856d5433b777f51a3defe13075325", + "sha256:aca749e190a01726a4fb472dd4ef23b5c9da7b9205c0a7857c06533de13fd678" ], "markers": "python_version >= '3.5'", - "version": "==5.0.2" + "version": "==5.1.1" }, "pyflakes": { "hashes": [ @@ -937,19 +950,19 @@ }, "unittest-xml-reporting": { "hashes": [ - "sha256:74eaf7739a7957a74f52b8187c5616f61157372189bef0a32ba5c30bbc00e58a", - "sha256:e09b8ae70cce9904cdd331f53bf929150962869a5324ab7ff3dd6c8b87e01f7d" + "sha256:7bf515ea8cb244255a25100cd29db611a73f8d3d0aaf672ed3266307e14cc1ca", + "sha256:984cebba69e889401bfe3adb9088ca376b3a1f923f0590d005126c1bffd1a695" ], "index": "pypi", - "version": "==3.0.2" + "version": "==3.0.4" }, "virtualenv": { "hashes": [ - "sha256:7b54fd606a1b85f83de49ad8d80dbec08e983a2d2f96685045b262ebc7481ee5", - "sha256:8cd7b2a4850b003a11be2fc213e206419efab41115cc14bca20e69654f2ac08e" + "sha256:43add625c53c596d38f971a465553f6318decc39d98512bc100fa1b1e839c8dc", + "sha256:e0305af10299a7fb0d69393d8f04cb2965dda9351140d11ac8db4e5e3970451b" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.0.30" + "version": "==20.0.31" } } } diff --git a/bot/__main__.py b/bot/__main__.py index fe2cf90e6..ee627be0a 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -1,7 +1,9 @@ +import asyncio import logging import discord import sentry_sdk +from async_rediscache import RedisSession from discord.ext.commands import when_mentioned_or from sentry_sdk.integrations.aiohttp import AioHttpIntegration from sentry_sdk.integrations.logging import LoggingIntegration @@ -24,8 +26,26 @@ sentry_sdk.init( ] ) +# Create the redis session instance. +redis_session = RedisSession( + address=(constants.Redis.host, constants.Redis.port), + password=constants.Redis.password, + minsize=1, + maxsize=20, + use_fakeredis=constants.Redis.use_fakeredis, +) + +# Connect redis session to ensure it's connected before we try to access Redis +# from somewhere within the bot. We create the event loop in the same way +# discord.py normally does and pass it to the bot's __init__. +loop = asyncio.get_event_loop() +loop.run_until_complete(redis_session.connect()) + + allowed_roles = [discord.Object(id_) for id_ in constants.MODERATION_ROLES] bot = Bot( + redis_session=redis_session, + loop=loop, command_prefix=when_mentioned_or(constants.Bot.prefix), activity=discord.Game(name="Commands: !help"), case_insensitive=True, diff --git a/bot/bot.py b/bot/bot.py index d25074fd9..b2e5237fe 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -6,9 +6,8 @@ from collections import defaultdict from typing import Dict, Optional import aiohttp -import aioredis import discord -import fakeredis.aioredis +from async_rediscache import RedisSession from discord.ext import commands from sentry_sdk import push_scope @@ -21,7 +20,7 @@ log = logging.getLogger('bot') class Bot(commands.Bot): """A subclass of `discord.ext.commands.Bot` with an aiohttp session and an API client.""" - def __init__(self, *args, **kwargs): + def __init__(self, *args, redis_session: RedisSession, **kwargs): if "connector" in kwargs: warnings.warn( "If login() is called (or the bot is started), the connector will be overwritten " @@ -31,9 +30,7 @@ class Bot(commands.Bot): super().__init__(*args, **kwargs) self.http_session: Optional[aiohttp.ClientSession] = None - self.redis_session: Optional[aioredis.Redis] = None - self.redis_ready = asyncio.Event() - self.redis_closed = False + self.redis_session = redis_session self.api_client = api.APIClient(loop=self.loop) self.filter_list_cache = defaultdict(dict) @@ -58,30 +55,6 @@ class Bot(commands.Bot): for item in full_cache: self.insert_item_into_filter_list_cache(item) - async def _create_redis_session(self) -> None: - """ - Create the Redis connection pool, and then open the redis event gate. - - If constants.Redis.use_fakeredis is True, we'll set up a fake redis pool instead - of attempting to communicate with a real Redis server. This is useful because it - means contributors don't necessarily need to get Redis running locally just - to run the bot. - - The fakeredis cache won't have persistence across restarts, but that - usually won't matter for local bot testing. - """ - if constants.Redis.use_fakeredis: - log.info("Using fakeredis instead of communicating with a real Redis server.") - self.redis_session = await fakeredis.aioredis.create_redis_pool() - else: - self.redis_session = await aioredis.create_redis_pool( - address=(constants.Redis.host, constants.Redis.port), - password=constants.Redis.password, - ) - - self.redis_closed = False - self.redis_ready.set() - def _recreate(self) -> None: """Re-create the connector, aiohttp session, the APIClient and the Redis session.""" # Use asyncio for DNS resolution instead of threads so threads aren't spammed. @@ -94,13 +67,10 @@ class Bot(commands.Bot): "The previous connector was not closed; it will remain open and be overwritten" ) - if self.redis_session and not self.redis_session.closed: - log.warning( - "The previous redis pool was not closed; it will remain open and be overwritten" - ) - - # Create the redis session - self.loop.create_task(self._create_redis_session()) + if self.redis_session.closed: + # If the RedisSession was somehow closed, we try to reconnect it + # here. Normally, this shouldn't happen. + self.loop.create_task(self.redis_session.connect()) # Use AF_INET as its socket family to prevent HTTPS related problems both locally # and in production. @@ -180,10 +150,7 @@ class Bot(commands.Bot): self.stats._transport.close() if self.redis_session: - self.redis_closed = True - self.redis_session.close() - self.redis_ready.clear() - await self.redis_session.wait_closed() + await self.redis_session.close() def insert_item_into_filter_list_cache(self, item: Dict[str, str]) -> None: """Add an item to the bots filter_list_cache.""" diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index 0d8f340b4..368d6a5d9 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -2,6 +2,7 @@ import logging from typing import Optional import discord +from async_rediscache import RedisCache from discord import Color from discord.ext import commands from discord.ext.commands import Cog @@ -9,7 +10,6 @@ from discord.ext.commands import Cog from bot import constants from bot.bot import Bot from bot.converters import UserMentionOrID -from bot.utils import RedisCache from bot.utils.checks import in_whitelist_check, with_role_check from bot.utils.messages import send_attachments from bot.utils.webhooks import send_webhook diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 99b659bff..0950ace60 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -6,6 +6,7 @@ from typing import List, Mapping, Optional, Tuple, Union import dateutil import discord.errors +from async_rediscache import RedisCache from dateutil.relativedelta import relativedelta from discord import Colour, HTTPException, Member, Message, NotFound, TextChannel from discord.ext.commands import Cog @@ -16,9 +17,8 @@ from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.constants import ( Channels, Colours, - Filter, Icons, URLs + Filter, Icons, URLs, ) -from bot.utils.redis_cache import RedisCache from bot.utils.regex import INVITE_RE from bot.utils.scheduling import Scheduler diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 0f9cac89e..d4feb636c 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -9,11 +9,11 @@ from pathlib import Path import discord import discord.abc +from async_rediscache import RedisCache from discord.ext import commands from bot import constants from bot.bot import Bot -from bot.utils import RedisCache from bot.utils.checks import with_role_check from bot.utils.scheduling import Scheduler diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 9ae92a228..859cc26e1 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -5,6 +5,7 @@ from contextlib import suppress from datetime import datetime, timedelta import discord +from async_rediscache import RedisCache from discord.ext import tasks from discord.ext.commands import Cog, Context, command, group from discord.utils import snowflake_time @@ -14,7 +15,6 @@ from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.decorators import in_whitelist, with_role, without_role from bot.utils.checks import InWhitelistCheckFailure, without_role_check -from bot.utils.redis_cache import RedisCache log = logging.getLogger(__name__) diff --git a/tests/helpers.py b/tests/helpers.py index facc4e1af..e47fdf28f 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -308,7 +308,11 @@ class MockBot(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.ext.commands.Bot` instances. For more information, see the `MockGuild` docstring. """ - spec_set = Bot(command_prefix=unittest.mock.MagicMock(), loop=_get_mock_loop()) + spec_set = Bot( + command_prefix=unittest.mock.MagicMock(), + loop=_get_mock_loop(), + redis_session=unittest.mock.MagicMock(), + ) additional_spec_asyncs = ("wait_for", "redis_ready") def __init__(self, **kwargs) -> None: -- cgit v1.2.3 From de4a8d960f9f845467efb470e17a0e9685df1bdc Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sat, 19 Sep 2020 21:48:41 +0200 Subject: Remove vestigial RedisCache class definition As we're now using the `async-rediscache` package, it's no longer necessary to keep the `RedisCache` defined in `bot.utils.redis_cache` around. I've removed the file containing it and the tests written for it. Signed-off-by: Sebastiaan Zeeff --- bot/utils/__init__.py | 3 +- bot/utils/redis_cache.py | 414 ------------------------------------ tests/bot/utils/test_redis_cache.py | 265 ----------------------- 3 files changed, 1 insertion(+), 681 deletions(-) delete mode 100644 bot/utils/redis_cache.py delete mode 100644 tests/bot/utils/test_redis_cache.py diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 3e93fcb06..60170a88f 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1,5 +1,4 @@ from bot.utils.helpers import CogABCMeta, find_nth_occurrence, pad_base64 -from bot.utils.redis_cache import RedisCache from bot.utils.services import send_to_paste_service -__all__ = ['RedisCache', 'CogABCMeta', 'find_nth_occurrence', 'pad_base64', 'send_to_paste_service'] +__all__ = ['CogABCMeta', 'find_nth_occurrence', 'pad_base64', 'send_to_paste_service'] diff --git a/bot/utils/redis_cache.py b/bot/utils/redis_cache.py deleted file mode 100644 index 52b689b49..000000000 --- a/bot/utils/redis_cache.py +++ /dev/null @@ -1,414 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -from functools import partialmethod -from typing import Any, Dict, ItemsView, Optional, Tuple, Union - -from bot.bot import Bot - -log = logging.getLogger(__name__) - -# Type aliases -RedisKeyType = Union[str, int] -RedisValueType = Union[str, int, float, bool] -RedisKeyOrValue = Union[RedisKeyType, RedisValueType] - -# Prefix tuples -_PrefixTuple = Tuple[Tuple[str, Any], ...] -_VALUE_PREFIXES = ( - ("f|", float), - ("i|", int), - ("s|", str), - ("b|", bool), -) -_KEY_PREFIXES = ( - ("i|", int), - ("s|", str), -) - - -class NoBotInstanceError(RuntimeError): - """Raised when RedisCache is created without an available bot instance on the owner class.""" - - -class NoNamespaceError(RuntimeError): - """Raised when RedisCache has no namespace, for example if it is not assigned to a class attribute.""" - - -class NoParentInstanceError(RuntimeError): - """Raised when the parent instance is available, for example if called by accessing the parent class directly.""" - - -class RedisCache: - """ - A simplified interface for a Redis connection. - - We implement several convenient methods that are fairly similar to have a dict - behaves, and should be familiar to Python users. The biggest difference is that - all the public methods in this class are coroutines, and must be awaited. - - Because of limitations in Redis, this cache will only accept strings and integers for keys, - and strings, integers, floats and booleans for values. - - Please note that this class MUST be created as a class attribute, and that that class - must also contain an attribute with an instance of our Bot. See `__get__` and `__set_name__` - for more information about how this works. - - Simple example for how to use this: - - class SomeCog(Cog): - # To initialize a valid RedisCache, just add it as a class attribute here. - # Do not add it to the __init__ method or anywhere else, it MUST be a class - # attribute. Do not pass any parameters. - cache = RedisCache() - - async def my_method(self): - - # Now we're ready to use the RedisCache. - # One thing to note here is that this will not work unless - # we access self.cache through an _instance_ of this class. - # - # For example, attempting to use SomeCog.cache will _not_ work, - # you _must_ instantiate the class first and use that instance. - # - # Now we can store some stuff in the cache just by doing this. - # This data will persist through restarts! - await self.cache.set("key", "value") - - # To get the data, simply do this. - value = await self.cache.get("key") - - # Other methods work more or less like a dictionary. - # Checking if something is in the cache - await self.cache.contains("key") - - # iterating the cache - async for key, value in self.cache.items(): - print(value) - - # We can even iterate in a comprehension! - consumed = [value async for key, value in self.cache.items()] - """ - - _namespaces = [] - - def __init__(self) -> None: - """Initialize the RedisCache.""" - self._namespace = None - self.bot = None - self._increment_lock = None - - def _set_namespace(self, namespace: str) -> None: - """Try to set the namespace, but do not permit collisions.""" - log.trace(f"RedisCache setting namespace to {namespace}") - self._namespaces.append(namespace) - self._namespace = namespace - - @staticmethod - def _to_typestring(key_or_value: RedisKeyOrValue, prefixes: _PrefixTuple) -> str: - """Turn a valid Redis type into a typestring.""" - for prefix, _type in prefixes: - # Convert bools into integers before storing them. - if type(key_or_value) is bool: - bool_int = int(key_or_value) - return f"{prefix}{bool_int}" - - # isinstance is a bad idea here, because isintance(False, int) == True. - if type(key_or_value) is _type: - return f"{prefix}{key_or_value}" - - raise TypeError(f"RedisCache._to_typestring only supports the following: {prefixes}.") - - @staticmethod - def _from_typestring(key_or_value: Union[bytes, str], prefixes: _PrefixTuple) -> RedisKeyOrValue: - """Deserialize a typestring into a valid Redis type.""" - # Stuff that comes out of Redis will be bytestrings, so let's decode those. - if isinstance(key_or_value, bytes): - key_or_value = key_or_value.decode('utf-8') - - # Now we convert our unicode string back into the type it originally was. - for prefix, _type in prefixes: - if key_or_value.startswith(prefix): - - # For booleans, we need special handling because bool("False") is True. - if prefix == "b|": - value = key_or_value[len(prefix):] - return bool(int(value)) - - # Otherwise we can just convert normally. - return _type(key_or_value[len(prefix):]) - raise TypeError(f"RedisCache._from_typestring only supports the following: {prefixes}.") - - # Add some nice partials to call our generic typestring converters. - # These are basically methods that will fill in some of the parameters for you, so that - # any call to _key_to_typestring will be like calling _to_typestring with the two parameters - # at `prefixes` and `types_string` pre-filled. - # - # See https://docs.python.org/3/library/functools.html#functools.partialmethod - _key_to_typestring = partialmethod(_to_typestring, prefixes=_KEY_PREFIXES) - _value_to_typestring = partialmethod(_to_typestring, prefixes=_VALUE_PREFIXES) - _key_from_typestring = partialmethod(_from_typestring, prefixes=_KEY_PREFIXES) - _value_from_typestring = partialmethod(_from_typestring, prefixes=_VALUE_PREFIXES) - - def _dict_from_typestring(self, dictionary: Dict) -> Dict: - """Turns all contents of a dict into valid Redis types.""" - return {self._key_from_typestring(key): self._value_from_typestring(value) for key, value in dictionary.items()} - - def _dict_to_typestring(self, dictionary: Dict) -> Dict: - """Turns all contents of a dict into typestrings.""" - return {self._key_to_typestring(key): self._value_to_typestring(value) for key, value in dictionary.items()} - - async def _validate_cache(self) -> None: - """Validate that the RedisCache is ready to be used.""" - if self._namespace is None: - error_message = ( - "Critical error: RedisCache has no namespace. " - "This object must be initialized as a class attribute." - ) - log.error(error_message) - raise NoNamespaceError(error_message) - - if self.bot is None: - error_message = ( - "Critical error: RedisCache has no `Bot` instance. " - "This happens when the class RedisCache was created in doesn't " - "have a Bot instance. Please make sure that you're instantiating " - "the RedisCache inside a class that has a Bot instance attribute." - ) - log.error(error_message) - raise NoBotInstanceError(error_message) - - if not self.bot.redis_closed: - await self.bot.redis_ready.wait() - - def __set_name__(self, owner: Any, attribute_name: str) -> None: - """ - Set the namespace to Class.attribute_name. - - Called automatically when this class is constructed inside a class as an attribute. - - This class MUST be created as a class attribute in a class, otherwise it will raise - exceptions whenever a method is used. This is because it uses this method to create - a namespace like `MyCog.my_class_attribute` which is used as a hash name when we store - stuff in Redis, to prevent collisions. - """ - self._set_namespace(f"{owner.__name__}.{attribute_name}") - - def __get__(self, instance: RedisCache, owner: Any) -> RedisCache: - """ - This is called if the RedisCache is a class attribute, and is accessed. - - The class this object is instantiated in must contain an attribute with an - instance of Bot. This is because Bot contains our redis_session, which is - the mechanism by which we will communicate with the Redis server. - - Any attempt to use RedisCache in a class that does not have a Bot instance - will fail. It is mostly intended to be used inside of a Cog, although theoretically - it should work in any class that has a Bot instance. - """ - if self.bot: - return self - - if self._namespace is None: - error_message = "RedisCache must be a class attribute." - log.error(error_message) - raise NoNamespaceError(error_message) - - if instance is None: - error_message = ( - "You must access the RedisCache instance through the cog instance " - "before accessing it using the cog's class object." - ) - log.error(error_message) - raise NoParentInstanceError(error_message) - - for attribute in vars(instance).values(): - if isinstance(attribute, Bot): - self.bot = attribute - return self - else: - error_message = ( - "Critical error: RedisCache has no `Bot` instance. " - "This happens when the class RedisCache was created in doesn't " - "have a Bot instance. Please make sure that you're instantiating " - "the RedisCache inside a class that has a Bot instance attribute." - ) - log.error(error_message) - raise NoBotInstanceError(error_message) - - def __repr__(self) -> str: - """Return a beautiful representation of this object instance.""" - return f"RedisCache(namespace={self._namespace!r})" - - async def set(self, key: RedisKeyType, value: RedisValueType) -> None: - """Store an item in the Redis cache.""" - await self._validate_cache() - - # Convert to a typestring and then set it - key = self._key_to_typestring(key) - value = self._value_to_typestring(value) - - log.trace(f"Setting {key} to {value}.") - await self.bot.redis_session.hset(self._namespace, key, value) - - async def get(self, key: RedisKeyType, default: Optional[RedisValueType] = None) -> Optional[RedisValueType]: - """Get an item from the Redis cache.""" - await self._validate_cache() - key = self._key_to_typestring(key) - - log.trace(f"Attempting to retrieve {key}.") - value = await self.bot.redis_session.hget(self._namespace, key) - - if value is None: - log.trace(f"Value not found, returning default value {default}") - return default - else: - value = self._value_from_typestring(value) - log.trace(f"Value found, returning value {value}") - return value - - async def delete(self, key: RedisKeyType) -> None: - """ - Delete an item from the Redis cache. - - If we try to delete a key that does not exist, it will simply be ignored. - - See https://redis.io/commands/hdel for more info on how this works. - """ - await self._validate_cache() - key = self._key_to_typestring(key) - - log.trace(f"Attempting to delete {key}.") - return await self.bot.redis_session.hdel(self._namespace, key) - - async def contains(self, key: RedisKeyType) -> bool: - """ - Check if a key exists in the Redis cache. - - Return True if the key exists, otherwise False. - """ - await self._validate_cache() - key = self._key_to_typestring(key) - exists = await self.bot.redis_session.hexists(self._namespace, key) - - log.trace(f"Testing if {key} exists in the RedisCache - Result is {exists}") - return exists - - async def items(self) -> ItemsView: - """ - Fetch all the key/value pairs in the cache. - - Returns a normal ItemsView, like you would get from dict.items(). - - Keep in mind that these items are just a _copy_ of the data in the - RedisCache - any changes you make to them will not be reflected - into the RedisCache itself. If you want to change these, you need - to make a .set call. - - Example: - items = await my_cache.items() - for key, value in items: - # Iterate like a normal dictionary - """ - await self._validate_cache() - items = self._dict_from_typestring( - await self.bot.redis_session.hgetall(self._namespace) - ).items() - - log.trace(f"Retrieving all key/value pairs from cache, total of {len(items)} items.") - return items - - async def length(self) -> int: - """Return the number of items in the Redis cache.""" - await self._validate_cache() - number_of_items = await self.bot.redis_session.hlen(self._namespace) - log.trace(f"Returning length. Result is {number_of_items}.") - return number_of_items - - async def to_dict(self) -> Dict: - """Convert to dict and return.""" - return {key: value for key, value in await self.items()} - - async def clear(self) -> None: - """Deletes the entire hash from the Redis cache.""" - await self._validate_cache() - log.trace("Clearing the cache of all key/value pairs.") - await self.bot.redis_session.delete(self._namespace) - - async def pop(self, key: RedisKeyType, default: Optional[RedisValueType] = None) -> RedisValueType: - """Get the item, remove it from the cache, and provide a default if not found.""" - log.trace(f"Attempting to pop {key}.") - value = await self.get(key, default) - - log.trace( - f"Attempting to delete item with key '{key}' from the cache. " - "If this key doesn't exist, nothing will happen." - ) - await self.delete(key) - - return value - - async def update(self, items: Dict[RedisKeyType, RedisValueType]) -> None: - """ - Update the Redis cache with multiple values. - - This works exactly like dict.update from a normal dictionary. You pass - a dictionary with one or more key/value pairs into this method. If the keys - do not exist in the RedisCache, they are created. If they do exist, the values - are updated with the new ones from `items`. - - Please note that keys and the values in the `items` dictionary - must consist of valid RedisKeyTypes and RedisValueTypes. - """ - await self._validate_cache() - log.trace(f"Updating the cache with the following items:\n{items}") - await self.bot.redis_session.hmset_dict(self._namespace, self._dict_to_typestring(items)) - - async def increment(self, key: RedisKeyType, amount: Optional[int, float] = 1) -> None: - """ - Increment the value by `amount`. - - This works for both floats and ints, but will raise a TypeError - if you try to do it for any other type of value. - - This also supports negative amounts, although it would provide better - readability to use .decrement() for that. - """ - log.trace(f"Attempting to increment/decrement the value with the key {key} by {amount}.") - - # We initialize the lock here, because we need to ensure we get it - # running on the same loop as the calling coroutine. - # - # If we initialized the lock in the __init__, the loop that the coroutine this method - # would be called from might not exist yet, and so the lock would be on a different - # loop, which would raise RuntimeErrors. - if self._increment_lock is None: - self._increment_lock = asyncio.Lock() - - # Since this has several API calls, we need a lock to prevent race conditions - async with self._increment_lock: - value = await self.get(key) - - # Can't increment a non-existing value - if value is None: - error_message = "The provided key does not exist!" - log.error(error_message) - raise KeyError(error_message) - - # If it does exist, and it's an int or a float, increment and set it. - if isinstance(value, int) or isinstance(value, float): - value += amount - await self.set(key, value) - else: - error_message = "You may only increment or decrement values that are integers or floats." - log.error(error_message) - raise TypeError(error_message) - - async def decrement(self, key: RedisKeyType, amount: Optional[int, float] = 1) -> None: - """ - Decrement the value by `amount`. - - Basically just does the opposite of .increment. - """ - await self.increment(key, -amount) diff --git a/tests/bot/utils/test_redis_cache.py b/tests/bot/utils/test_redis_cache.py deleted file mode 100644 index a2f0fe55d..000000000 --- a/tests/bot/utils/test_redis_cache.py +++ /dev/null @@ -1,265 +0,0 @@ -import asyncio -import unittest - -import fakeredis.aioredis - -from bot.utils import RedisCache -from bot.utils.redis_cache import NoBotInstanceError, NoNamespaceError, NoParentInstanceError -from tests import helpers - - -class RedisCacheTests(unittest.IsolatedAsyncioTestCase): - """Tests the RedisCache class from utils.redis_dict.py.""" - - async def asyncSetUp(self): # noqa: N802 - """Sets up the objects that only have to be initialized once.""" - self.bot = helpers.MockBot() - self.bot.redis_session = await fakeredis.aioredis.create_redis_pool() - - # Okay, so this is necessary so that we can create a clean new - # class for every test method, and we want that because it will - # ensure we get a fresh loop, which is necessary for test_increment_lock - # to be able to pass. - class DummyCog: - """A dummy cog, for dummies.""" - - redis = RedisCache() - - def __init__(self, bot: helpers.MockBot): - self.bot = bot - - self.cog = DummyCog(self.bot) - - await self.cog.redis.clear() - - def test_class_attribute_namespace(self): - """Test that RedisDict creates a namespace automatically for class attributes.""" - self.assertEqual(self.cog.redis._namespace, "DummyCog.redis") - - async def test_class_attribute_required(self): - """Test that errors are raised when not assigned as a class attribute.""" - bad_cache = RedisCache() - self.assertIs(bad_cache._namespace, None) - - with self.assertRaises(RuntimeError): - await bad_cache.set("test", "me_up_deadman") - - async def test_set_get_item(self): - """Test that users can set and get items from the RedisDict.""" - test_cases = ( - ('favorite_fruit', 'melon'), - ('favorite_number', 86), - ('favorite_fraction', 86.54), - ('favorite_boolean', False), - ('other_boolean', True), - ) - - # Test that we can get and set different types. - for test in test_cases: - await self.cog.redis.set(*test) - self.assertEqual(await self.cog.redis.get(test[0]), test[1]) - - # Test that .get allows a default value - self.assertEqual(await self.cog.redis.get('favorite_nothing', "bearclaw"), "bearclaw") - - async def test_set_item_type(self): - """Test that .set rejects keys and values that are not permitted.""" - fruits = ["lemon", "melon", "apple"] - - with self.assertRaises(TypeError): - await self.cog.redis.set(fruits, "nice") - - with self.assertRaises(TypeError): - await self.cog.redis.set(4.23, "nice") - - async def test_delete_item(self): - """Test that .delete allows us to delete stuff from the RedisCache.""" - # Add an item and verify that it gets added - await self.cog.redis.set("internet", "firetruck") - self.assertEqual(await self.cog.redis.get("internet"), "firetruck") - - # Delete that item and verify that it gets deleted - await self.cog.redis.delete("internet") - self.assertIs(await self.cog.redis.get("internet"), None) - - async def test_contains(self): - """Test that we can check membership with .contains.""" - await self.cog.redis.set('favorite_country', "Burkina Faso") - - self.assertIs(await self.cog.redis.contains('favorite_country'), True) - self.assertIs(await self.cog.redis.contains('favorite_dentist'), False) - - async def test_items(self): - """Test that the RedisDict can be iterated.""" - # Set up our test cases in the Redis cache - test_cases = [ - ('favorite_turtle', 'Donatello'), - ('second_favorite_turtle', 'Leonardo'), - ('third_favorite_turtle', 'Raphael'), - ] - for key, value in test_cases: - await self.cog.redis.set(key, value) - - # Consume the AsyncIterator into a regular list, easier to compare that way. - redis_items = [item for item in await self.cog.redis.items()] - - # These sequences are probably in the same order now, but probably - # isn't good enough for tests. Let's not rely on .hgetall always - # returning things in sequence, and just sort both lists to be safe. - redis_items = sorted(redis_items) - test_cases = sorted(test_cases) - - # If these are equal now, everything works fine. - self.assertSequenceEqual(test_cases, redis_items) - - async def test_length(self): - """Test that we can get the correct .length from the RedisDict.""" - await self.cog.redis.set('one', 1) - await self.cog.redis.set('two', 2) - await self.cog.redis.set('three', 3) - self.assertEqual(await self.cog.redis.length(), 3) - - await self.cog.redis.set('four', 4) - self.assertEqual(await self.cog.redis.length(), 4) - - async def test_to_dict(self): - """Test that the .to_dict method returns a workable dictionary copy.""" - copy = await self.cog.redis.to_dict() - local_copy = {key: value for key, value in await self.cog.redis.items()} - self.assertIs(type(copy), dict) - self.assertDictEqual(copy, local_copy) - - async def test_clear(self): - """Test that the .clear method removes the entire hash.""" - await self.cog.redis.set('teddy', 'with me') - await self.cog.redis.set('in my dreams', 'you have a weird hat') - self.assertEqual(await self.cog.redis.length(), 2) - - await self.cog.redis.clear() - self.assertEqual(await self.cog.redis.length(), 0) - - async def test_pop(self): - """Test that we can .pop an item from the RedisDict.""" - await self.cog.redis.set('john', 'was afraid') - - self.assertEqual(await self.cog.redis.pop('john'), 'was afraid') - self.assertEqual(await self.cog.redis.pop('pete', 'breakneck'), 'breakneck') - self.assertEqual(await self.cog.redis.length(), 0) - - async def test_update(self): - """Test that we can .update the RedisDict with multiple items.""" - await self.cog.redis.set("reckfried", "lona") - await self.cog.redis.set("bel air", "prince") - await self.cog.redis.update({ - "reckfried": "jona", - "mega": "hungry, though", - }) - - result = { - "reckfried": "jona", - "bel air": "prince", - "mega": "hungry, though", - } - self.assertDictEqual(await self.cog.redis.to_dict(), result) - - def test_typestring_conversion(self): - """Test the typestring-related helper functions.""" - conversion_tests = ( - (12, "i|12"), - (12.4, "f|12.4"), - ("cowabunga", "s|cowabunga"), - ) - - # Test conversion to typestring - for _input, expected in conversion_tests: - self.assertEqual(self.cog.redis._value_to_typestring(_input), expected) - - # Test conversion from typestrings - for _input, expected in conversion_tests: - self.assertEqual(self.cog.redis._value_from_typestring(expected), _input) - - # Test that exceptions are raised on invalid input - with self.assertRaises(TypeError): - self.cog.redis._value_to_typestring(["internet"]) - self.cog.redis._value_from_typestring("o|firedog") - - async def test_increment_decrement(self): - """Test .increment and .decrement methods.""" - await self.cog.redis.set("entropic", 5) - await self.cog.redis.set("disentropic", 12.5) - - # Test default increment - await self.cog.redis.increment("entropic") - self.assertEqual(await self.cog.redis.get("entropic"), 6) - - # Test default decrement - await self.cog.redis.decrement("entropic") - self.assertEqual(await self.cog.redis.get("entropic"), 5) - - # Test float increment with float - await self.cog.redis.increment("disentropic", 2.0) - self.assertEqual(await self.cog.redis.get("disentropic"), 14.5) - - # Test float increment with int - await self.cog.redis.increment("disentropic", 2) - self.assertEqual(await self.cog.redis.get("disentropic"), 16.5) - - # Test negative increments, because why not. - await self.cog.redis.increment("entropic", -5) - self.assertEqual(await self.cog.redis.get("entropic"), 0) - - # Negative decrements? Sure. - await self.cog.redis.decrement("entropic", -5) - self.assertEqual(await self.cog.redis.get("entropic"), 5) - - # What about if we use a negative float to decrement an int? - # This should convert the type into a float. - await self.cog.redis.decrement("entropic", -2.5) - self.assertEqual(await self.cog.redis.get("entropic"), 7.5) - - # Let's test that they raise the right errors - with self.assertRaises(KeyError): - await self.cog.redis.increment("doesn't_exist!") - - await self.cog.redis.set("stringthing", "stringthing") - with self.assertRaises(TypeError): - await self.cog.redis.increment("stringthing") - - async def test_increment_lock(self): - """Test that we can't produce a race condition in .increment.""" - await self.cog.redis.set("test_key", 0) - tasks = [] - - # Increment this a lot in different tasks - for _ in range(100): - task = asyncio.create_task( - self.cog.redis.increment("test_key", 1) - ) - tasks.append(task) - await asyncio.gather(*tasks) - - # Confirm that the value has been incremented the exact right number of times. - value = await self.cog.redis.get("test_key") - self.assertEqual(value, 100) - - async def test_exceptions_raised(self): - """Testing that the various RuntimeErrors are reachable.""" - class MyCog: - cache = RedisCache() - - def __init__(self): - self.other_cache = RedisCache() - - cog = MyCog() - - # Raises "No Bot instance" - with self.assertRaises(NoBotInstanceError): - await cog.cache.get("john") - - # Raises "RedisCache has no namespace" - with self.assertRaises(NoNamespaceError): - await cog.other_cache.get("was") - - # Raises "You must access the RedisCache instance through the cog instance" - with self.assertRaises(NoParentInstanceError): - await MyCog.cache.get("afraid") -- cgit v1.2.3 From 9bb3a4880205e9b24bb839de60ca2f5a26689067 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sat, 19 Sep 2020 22:01:36 +0200 Subject: Use global namespace `bot` for our RedisSession As we're now planning on using Redis in multiple applications, it's important to minimize the risk of namespace collisions between different applications. The `async-rediscache` packages allows us to set a global namespace on an application level. I've chosen "bot" as the namespace for this application, which means all individual namespaces will automatically be prefixed by `bot.` whenever they are accessed. Signed-off-by: Sebastiaan Zeeff --- bot/__main__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/__main__.py b/bot/__main__.py index ee627be0a..fb0021d5d 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -33,6 +33,7 @@ redis_session = RedisSession( minsize=1, maxsize=20, use_fakeredis=constants.Redis.use_fakeredis, + global_namespace="bot", ) # Connect redis session to ensure it's connected before we try to access Redis -- cgit v1.2.3 From 409f0b5e5a0f71a0858c91ffde46275cf755f067 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sun, 20 Sep 2020 11:41:48 +0200 Subject: Determine eligible duckpond emojis dynamically Instead of maintaining a list of duckpond IDs manually, it's a much better idea to detect ducky emojis dynamically using the new emoji name grouping we've introduced: All emojis that start with `ducky_` will now be counted as a duckpond ducky. The unicode duck emoji obviously still counts in addition to custom emojis with the `ducky_` prefix. Signed-off-by: Sebastiaan Zeeff --- bot/cogs/duck_pond.py | 54 +++++++++++++++++++++++++-------------------------- bot/constants.py | 15 -------------- config-default.yml | 28 -------------------------- 3 files changed, 27 insertions(+), 70 deletions(-) diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index 7021069fa..e1aceb482 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -49,32 +49,32 @@ class DuckPond(Cog): return True return False + @staticmethod + def _is_duck_emoji(emoji: Union[str, discord.PartialEmoji, discord.Emoji]) -> bool: + """Check if the emoji is a valid duck emoji.""" + if isinstance(emoji, str): + return emoji == "🦆" + else: + return hasattr(emoji, "name") and emoji.name.startswith("ducky_") + async def count_ducks(self, message: Message) -> int: """ Count the number of ducks in the reactions of a specific message. Only counts ducks added by staff members. """ - duck_count = 0 - duck_reactors = [] + duck_reactors = set() + # iterate over all reactions for reaction in message.reactions: - async for user in reaction.users(): - - # Is the user a staff member and not already counted as reactor? - if not self.is_staff(user) or user.id in duck_reactors: - continue - - # Is the emoji a duck? - if hasattr(reaction.emoji, "id"): - if reaction.emoji.id in constants.DuckPond.custom_emojis: - duck_count += 1 - duck_reactors.append(user.id) - elif isinstance(reaction.emoji, str): - if reaction.emoji == "🦆": - duck_count += 1 - duck_reactors.append(user.id) - return duck_count + # check if the current reaction is a duck + if not self._is_duck_emoji(reaction.emoji): + continue + + # update the set of reactors with all staff reactors + duck_reactors |= {user.id async for user in reaction.users() if self.is_staff(user)} + + return len(duck_reactors) async def relay_message(self, message: Message) -> None: """Relays the message's content and attachments to the duck pond channel.""" @@ -105,16 +105,16 @@ class DuckPond(Cog): await message.add_reaction("✅") - @staticmethod - def _payload_has_duckpond_emoji(payload: RawReactionActionEvent) -> bool: + def _payload_has_duckpond_emoji(self, emoji: discord.PartialEmoji) -> bool: """Test if the RawReactionActionEvent payload contains a duckpond emoji.""" - if payload.emoji.is_custom_emoji(): - if payload.emoji.id in constants.DuckPond.custom_emojis: - return True - elif payload.emoji.name == "🦆": - return True + if emoji.is_unicode_emoji(): + # For unicode PartialEmojis, the `name` attribute is just the string + # representation of the emoji. This is what the helper method + # expects, as unicode emojis show up as just a `str` instance when + # inspecting the reactions attached to a message. + emoji = emoji.name - return False + return self._is_duck_emoji(emoji) @Cog.listener() async def on_raw_reaction_add(self, payload: RawReactionActionEvent) -> None: @@ -126,7 +126,7 @@ class DuckPond(Cog): send the message off to the duck pond. """ # Is the emoji in the reaction a duck? - if not self._payload_has_duckpond_emoji(payload): + if not self._payload_has_duckpond_emoji(payload.emoji): return channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id) diff --git a/bot/constants.py b/bot/constants.py index 17f14fec0..f087fd96f 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -252,7 +252,6 @@ class DuckPond(metaclass=YAMLGetter): section = "duck_pond" threshold: int - custom_emojis: List[int] class Emojis(metaclass=YAMLGetter): @@ -292,20 +291,6 @@ class Emojis(metaclass=YAMLGetter): cross_mark: str check_mark: str - ducky_yellow: int - ducky_blurple: int - ducky_regal: int - ducky_camo: int - ducky_ninja: int - ducky_devil: int - ducky_tube: int - ducky_hunt: int - ducky_wizard: int - ducky_party: int - ducky_angel: int - ducky_maul: int - ducky_santa: int - upvotes: str comments: str user: str diff --git a/config-default.yml b/config-default.yml index 58651f548..8d13b2d11 100644 --- a/config-default.yml +++ b/config-default.yml @@ -62,20 +62,6 @@ style: cross_mark: "\u274C" check_mark: "\u2705" - ducky_yellow: &DUCKY_YELLOW 574951975574175744 - ducky_blurple: &DUCKY_BLURPLE 574951975310065675 - ducky_regal: &DUCKY_REGAL 637883439185395712 - ducky_camo: &DUCKY_CAMO 637914731566596096 - ducky_ninja: &DUCKY_NINJA 637923502535606293 - ducky_devil: &DUCKY_DEVIL 637925314982576139 - ducky_tube: &DUCKY_TUBE 637881368008851456 - ducky_hunt: &DUCKY_HUNT 639355090909528084 - ducky_wizard: &DUCKY_WIZARD 639355996954689536 - ducky_party: &DUCKY_PARTY 639468753440210977 - ducky_angel: &DUCKY_ANGEL 640121935610511361 - ducky_maul: &DUCKY_MAUL 640137724958867467 - ducky_santa: &DUCKY_SANTA 655360331002019870 - # emotes used for #reddit upvotes: "<:reddit_upvotes:755845219890757644>" comments: "<:reddit_comments:755845255001014384>" @@ -468,20 +454,6 @@ sync: duck_pond: threshold: 5 - custom_emojis: - - *DUCKY_YELLOW - - *DUCKY_BLURPLE - - *DUCKY_CAMO - - *DUCKY_DEVIL - - *DUCKY_NINJA - - *DUCKY_REGAL - - *DUCKY_TUBE - - *DUCKY_HUNT - - *DUCKY_WIZARD - - *DUCKY_PARTY - - *DUCKY_ANGEL - - *DUCKY_MAUL - - *DUCKY_SANTA python_news: mail_lists: -- cgit v1.2.3 From 3fea32523d84ee5e2ba0e68710191ffb61220d58 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sun, 20 Sep 2020 12:03:04 +0200 Subject: Ignore non-staff messages for our duckpond Some of our members have expressed concern that their messages would be "ducked" by staff members and relayed to the staff-only duckpond. Since duckpond is supposed to be a funny, staff-only affair, I've made duckpond ignore messages from non-staff members. Signed-off-by: Sebastiaan Zeeff --- bot/cogs/duck_pond.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index e1aceb482..66e862ab2 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -133,7 +133,11 @@ class DuckPond(Cog): message = await channel.fetch_message(payload.message_id) member = discord.utils.get(message.guild.members, id=payload.user_id) - # Is the member a human and a staff member? + # Was the message sent by a human staff member? + if not self.is_staff(message.author) or message.author.bot: + return + + # Is the reactor a human staff member? if not self.is_staff(member) or member.bot: return -- cgit v1.2.3 From 9410194c058675428c4ed99f403e84cb65c18a71 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sun, 20 Sep 2020 12:04:56 +0200 Subject: Add channel blacklist for duckpond As announcements already get a lot of exposure and have a high risk of getting "ducked", duckpond will now ignore those channels and never relay those announcements to our duckpond. Signed-off-by: Sebastiaan Zeeff --- bot/cogs/duck_pond.py | 4 ++++ bot/constants.py | 8 ++++++++ config-default.yml | 30 ++++++++++++++++++++++++++---- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index 66e862ab2..84c0c4265 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -125,6 +125,10 @@ class DuckPond(Cog): amount of ducks specified in the config under duck_pond/threshold, it will send the message off to the duck pond. """ + # Was this reaction issued in a blacklisted channel? + if payload.channel_id in constants.DuckPond.channel_blacklist: + return + # Is the emoji in the reaction a duck? if not self._payload_has_duckpond_emoji(payload.emoji): return diff --git a/bot/constants.py b/bot/constants.py index f087fd96f..aec0ffce3 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -252,6 +252,7 @@ class DuckPond(metaclass=YAMLGetter): section = "duck_pond" threshold: int + channel_blacklist: List[int] class Emojis(metaclass=YAMLGetter): @@ -380,12 +381,14 @@ class Channels(metaclass=YAMLGetter): section = "guild" subsection = "channels" + admin_announcements: int admin_spam: int admins: int announcements: int attachment_log: int big_brother_logs: int bot_commands: int + change_log: int cooldown: int defcon: int dev_contrib: int @@ -397,9 +400,11 @@ class Channels(metaclass=YAMLGetter): how_to_get_help: int incidents: int incidents_archive: int + mailing_lists: int message_log: int meta: int mod_alerts: int + mod_announcements: int mod_log: int mod_spam: int mods: int @@ -408,7 +413,10 @@ class Channels(metaclass=YAMLGetter): off_topic_2: int organisation: int python_discussion: int + python_events: int + python_news: int reddit: int + staff_announcements: int talent_pool: int user_event_announcements: int user_log: int diff --git a/config-default.yml b/config-default.yml index 8d13b2d11..4c1c7e483 100644 --- a/config-default.yml +++ b/config-default.yml @@ -130,9 +130,14 @@ guild: modmail: 714494672835444826 channels: - announcements: 354619224620138496 - user_event_announcements: &USER_EVENT_A 592000283102674944 - python_news: &PYNEWS_CHANNEL 704372456592506880 + # Public announcement and news channels + change_log: &CHANGE_LOG 748238795236704388 + announcements: &ANNOUNCEMENTS 354619224620138496 + python_news: &PYNEWS_CHANNEL 704372456592506880 + python_events: &PYEVENTS_CHANNEL 729674110270963822 + mailing_lists: &MAILING_LISTS 704372456592506880 + reddit: &REDDIT_CHANNEL 458224812528238616 + user_event_announcements: &USER_EVENT_A 592000283102674944 # Development dev_contrib: &DEV_CONTRIB 635950537262759947 @@ -163,7 +168,6 @@ guild: # Special bot_commands: &BOT_CMD 267659945086812160 esoteric: 470884583684964352 - reddit: 458224812528238616 verification: 352442727016693763 # Staff @@ -178,6 +182,12 @@ guild: mod_spam: &MOD_SPAM 620607373828030464 organisation: &ORGANISATION 551789653284356126 staff_lounge: &STAFF_LOUNGE 464905259261755392 + duck_pond: &DUCK_POND 637820308341915648 + + # Staff announcement channels + staff_announcements: &STAFF_ANNOUNCEMENTS 464033278631084042 + mod_announcements: &MOD_ANNOUNCEMENTS 372115205867700225 + admin_announcements: &ADMIN_ANNOUNCEMENTS 749736155569848370 # Voice admins_voice: &ADMINS_VOICE 500734494840717332 @@ -454,6 +464,18 @@ sync: duck_pond: threshold: 5 + channel_blacklist: + - *ANNOUNCEMENTS + - *PYNEWS_CHANNEL + - *PYEVENTS_CHANNEL + - *MAILING_LISTS + - *REDDIT_CHANNEL + - *USER_EVENT_A + - *DUCK_POND + - *CHANGE_LOG + - *STAFF_ANNOUNCEMENTS + - *MOD_ANNOUNCEMENTS + - *ADMIN_ANNOUNCEMENTS python_news: mail_lists: -- cgit v1.2.3 From d68d6d2858b8df74c48c00c0af23de24aa5022dc Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sun, 20 Sep 2020 12:17:30 +0200 Subject: Fix relay race condition in duckpond using a lock Our duckpond suffered from a race condition: If multiple raw reaction events were received in quick succession and a message had enough ducks to take it over the duckpond threshold, the message would be relayed multiple times. The reason this happened is because the green checkmark emoji that stops a message from being relayed multiple times is only added after the message has been relayed. This means that multiple event triggers can make it past the green checkmark check before any of them has a chance to add a green checkmark. The solution was to create a relay lock that needs to be acquired before checking for the presence of a green checkmark and is only released after adding a green checkmark. This prevents multiple events from making it past the sentinel check. As our Cogs are potentially initialized before the event loop is created, the lock is load lazily when needed. Signed-off-by: Sebastiaan Zeeff --- bot/cogs/duck_pond.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index 84c0c4265..12f4cb7b8 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -1,3 +1,4 @@ +import asyncio import logging from typing import Union @@ -21,6 +22,7 @@ class DuckPond(Cog): self.webhook_id = constants.Webhooks.duck_pond self.webhook = None self.bot.loop.create_task(self.fetch_webhook()) + self.relay_lock = None async def fetch_webhook(self) -> None: """Fetches the webhook object, so we can post to it.""" @@ -103,8 +105,6 @@ class DuckPond(Cog): except discord.HTTPException: log.exception("Failed to send an attachment to the webhook") - await message.add_reaction("✅") - def _payload_has_duckpond_emoji(self, emoji: discord.PartialEmoji) -> bool: """Test if the RawReactionActionEvent payload contains a duckpond emoji.""" if emoji.is_unicode_emoji(): @@ -145,16 +145,26 @@ class DuckPond(Cog): if not self.is_staff(member) or member.bot: return - # Does the message already have a green checkmark? - if await self.has_green_checkmark(message): - return - # Time to count our ducks! duck_count = await self.count_ducks(message) # If we've got more than the required amount of ducks, send the message to the duck_pond. if duck_count >= constants.DuckPond.threshold: - await self.relay_message(message) + if self.relay_lock is None: + # Lazily load the lock to ensure it's created within the + # appropriate event loop. + self.relay_lock = asyncio.Lock() + + async with self.relay_lock: + # check if the message has a checkmark after acquiring the lock + if await self.has_green_checkmark(message): + return + + # relay the message + await self.relay_message(message) + + # add a green checkmark to indicate that the message was relayed + await message.add_reaction("✅") @Cog.listener() async def on_raw_reaction_remove(self, payload: RawReactionActionEvent) -> None: -- cgit v1.2.3 From 0c5c472362d2891853bb82b225aa86da74d597e2 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sun, 20 Sep 2020 12:43:00 +0200 Subject: Remove unit tests for duck pond I've removed the unit tests for duckpond in concordance with the new policy for writing unit tests for the bot The tests were unnecessarily complicated, difficult to maintain, and slowed development. Signed-off-by: Sebastiaan Zeeff --- tests/bot/cogs/test_duck_pond.py | 548 --------------------------------------- 1 file changed, 548 deletions(-) delete mode 100644 tests/bot/cogs/test_duck_pond.py diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py deleted file mode 100644 index cfe10aebf..000000000 --- a/tests/bot/cogs/test_duck_pond.py +++ /dev/null @@ -1,548 +0,0 @@ -import asyncio -import logging -import typing -import unittest -from unittest.mock import AsyncMock, MagicMock, patch - -import discord - -from bot import constants -from bot.cogs import duck_pond -from tests import base -from tests import helpers - -MODULE_PATH = "bot.cogs.duck_pond" - - -class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): - """Tests for DuckPond functionality.""" - - @classmethod - def setUpClass(cls): - """Sets up the objects that only have to be initialized once.""" - cls.nonstaff_member = helpers.MockMember(name="Non-staffer") - - cls.staff_role = helpers.MockRole(name="Staff role", id=constants.STAFF_ROLES[0]) - cls.staff_member = helpers.MockMember(name="staffer", roles=[cls.staff_role]) - - cls.checkmark_emoji = "\N{White Heavy Check Mark}" - cls.thumbs_up_emoji = "\N{Thumbs Up Sign}" - cls.unicode_duck_emoji = "\N{Duck}" - cls.duck_pond_emoji = helpers.MockPartialEmoji(id=constants.DuckPond.custom_emojis[0]) - cls.non_duck_custom_emoji = helpers.MockPartialEmoji(id=123) - - def setUp(self): - """Sets up the objects that need to be refreshed before each test.""" - self.bot = helpers.MockBot(user=helpers.MockMember(id=46692)) - self.cog = duck_pond.DuckPond(bot=self.bot) - - def test_duck_pond_correctly_initializes(self): - """`__init__ should set `bot` and `webhook_id` attributes and schedule `fetch_webhook`.""" - bot = helpers.MockBot() - cog = MagicMock() - - duck_pond.DuckPond.__init__(cog, bot) - - self.assertEqual(cog.bot, bot) - self.assertEqual(cog.webhook_id, constants.Webhooks.duck_pond) - bot.loop.create_task.assert_called_once_with(cog.fetch_webhook()) - - def test_fetch_webhook_succeeds_without_connectivity_issues(self): - """The `fetch_webhook` method waits until `READY` event and sets the `webhook` attribute.""" - self.bot.fetch_webhook.return_value = "dummy webhook" - self.cog.webhook_id = 1 - - asyncio.run(self.cog.fetch_webhook()) - - self.bot.wait_until_guild_available.assert_called_once() - self.bot.fetch_webhook.assert_called_once_with(1) - self.assertEqual(self.cog.webhook, "dummy webhook") - - def test_fetch_webhook_logs_when_unable_to_fetch_webhook(self): - """The `fetch_webhook` method should log an exception when it fails to fetch the webhook.""" - self.bot.fetch_webhook.side_effect = discord.HTTPException(response=MagicMock(), message="Not found.") - self.cog.webhook_id = 1 - - log = logging.getLogger('bot.cogs.duck_pond') - with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: - asyncio.run(self.cog.fetch_webhook()) - - self.bot.wait_until_guild_available.assert_called_once() - self.bot.fetch_webhook.assert_called_once_with(1) - - self.assertEqual(len(log_watcher.records), 1) - - record = log_watcher.records[0] - self.assertEqual(record.levelno, logging.ERROR) - - def test_is_staff_returns_correct_values_based_on_instance_passed(self): - """The `is_staff` method should return correct values based on the instance passed.""" - test_cases = ( - (helpers.MockUser(name="User instance"), False), - (helpers.MockMember(name="Member instance without staff role"), False), - (helpers.MockMember(name="Member instance with staff role", roles=[self.staff_role]), True) - ) - - for user, expected_return in test_cases: - actual_return = self.cog.is_staff(user) - with self.subTest(user_type=user.name, expected_return=expected_return, actual_return=actual_return): - self.assertEqual(expected_return, actual_return) - - async def test_has_green_checkmark_correctly_detects_presence_of_green_checkmark_emoji(self): - """The `has_green_checkmark` method should only return `True` if one is present.""" - test_cases = ( - ( - "No reactions", helpers.MockMessage(), False - ), - ( - "No green check mark reactions", - helpers.MockMessage(reactions=[ - helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]), - helpers.MockReaction(emoji=self.thumbs_up_emoji, users=[self.bot.user]) - ]), - False - ), - ( - "Green check mark reaction, but not from our bot", - helpers.MockMessage(reactions=[ - helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]), - helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.staff_member]) - ]), - False - ), - ( - "Green check mark reaction, with one from the bot", - helpers.MockMessage(reactions=[ - helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]), - helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.staff_member, self.bot.user]) - ]), - True - ) - ) - - for description, message, expected_return in test_cases: - actual_return = await self.cog.has_green_checkmark(message) - with self.subTest( - test_case=description, - expected_return=expected_return, - actual_return=actual_return - ): - self.assertEqual(expected_return, actual_return) - - def _get_reaction( - self, - emoji: typing.Union[str, helpers.MockEmoji], - staff: int = 0, - nonstaff: int = 0 - ) -> helpers.MockReaction: - staffers = [helpers.MockMember(roles=[self.staff_role]) for _ in range(staff)] - nonstaffers = [helpers.MockMember() for _ in range(nonstaff)] - return helpers.MockReaction(emoji=emoji, users=staffers + nonstaffers) - - async def test_count_ducks_correctly_counts_the_number_of_eligible_duck_emojis(self): - """The `count_ducks` method should return the number of unique staffers who gave a duck.""" - test_cases = ( - # Simple test cases - # A message without reactions should return 0 - ( - "No reactions", - helpers.MockMessage(), - 0 - ), - # A message with a non-duck reaction from a non-staffer should return 0 - ( - "Non-duck reaction from non-staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.thumbs_up_emoji, nonstaff=1)]), - 0 - ), - # A message with a non-duck reaction from a staffer should return 0 - ( - "Non-duck reaction from staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.non_duck_custom_emoji, staff=1)]), - 0 - ), - # A message with a non-duck reaction from a non-staffer and staffer should return 0 - ( - "Non-duck reaction from staffer + non-staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.thumbs_up_emoji, staff=1, nonstaff=1)]), - 0 - ), - # A message with a unicode duck reaction from a non-staffer should return 0 - ( - "Unicode Duck Reaction from non-staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, nonstaff=1)]), - 0 - ), - # A message with a unicode duck reaction from a staffer should return 1 - ( - "Unicode Duck Reaction from staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, staff=1)]), - 1 - ), - # A message with a unicode duck reaction from a non-staffer and staffer should return 1 - ( - "Unicode Duck Reaction from staffer + non-staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, staff=1, nonstaff=1)]), - 1 - ), - # A message with a duckpond duck reaction from a non-staffer should return 0 - ( - "Duckpond Duck Reaction from non-staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, nonstaff=1)]), - 0 - ), - # A message with a duckpond duck reaction from a staffer should return 1 - ( - "Duckpond Duck Reaction from staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=1)]), - 1 - ), - # A message with a duckpond duck reaction from a non-staffer and staffer should return 1 - ( - "Duckpond Duck Reaction from staffer + non-staffer", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=1, nonstaff=1)]), - 1 - ), - - # Complex test cases - # A message with duckpond duck reactions from 3 staffers and 2 non-staffers returns 3 - ( - "Duckpond Duck Reaction from 3 staffers + 2 non-staffers", - helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=3, nonstaff=2)]), - 3 - ), - # A staffer with multiple duck reactions only counts once - ( - "Two different duck reactions from the same staffer", - helpers.MockMessage( - reactions=[ - helpers.MockReaction(emoji=self.duck_pond_emoji, users=[self.staff_member]), - helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.staff_member]), - ] - ), - 1 - ), - # A non-string emoji does not count (to test the `isinstance(reaction.emoji, str)` elif) - ( - "Reaction with non-Emoji/str emoij from 3 staffers + 2 non-staffers", - helpers.MockMessage(reactions=[self._get_reaction(emoji=100, staff=3, nonstaff=2)]), - 0 - ), - # We correctly sum when multiple reactions are provided. - ( - "Duckpond Duck Reaction from 3 staffers + 2 non-staffers", - helpers.MockMessage( - reactions=[ - self._get_reaction(emoji=self.duck_pond_emoji, staff=3, nonstaff=2), - self._get_reaction(emoji=self.unicode_duck_emoji, staff=4, nonstaff=9), - ] - ), - 3 + 4 - ), - ) - - for description, message, expected_count in test_cases: - actual_count = await self.cog.count_ducks(message) - with self.subTest(test_case=description, expected_count=expected_count, actual_count=actual_count): - self.assertEqual(expected_count, actual_count) - - async def test_relay_message_correctly_relays_content_and_attachments(self): - """The `relay_message` method should correctly relay message content and attachments.""" - send_webhook_path = f"{MODULE_PATH}.send_webhook" - send_attachments_path = f"{MODULE_PATH}.send_attachments" - author = MagicMock( - display_name="x", - avatar_url="https://" - ) - - self.cog.webhook = helpers.MockAsyncWebhook() - - test_values = ( - (helpers.MockMessage(author=author, clean_content="", attachments=[]), False, False), - (helpers.MockMessage(author=author, clean_content="message", attachments=[]), True, False), - (helpers.MockMessage(author=author, clean_content="", attachments=["attachment"]), False, True), - (helpers.MockMessage(author=author, clean_content="message", attachments=["attachment"]), True, True), - ) - - for message, expect_webhook_call, expect_attachment_call in test_values: - with patch(send_webhook_path, new_callable=AsyncMock) as send_webhook: - with patch(send_attachments_path, new_callable=AsyncMock) as send_attachments: - with self.subTest(clean_content=message.clean_content, attachments=message.attachments): - await self.cog.relay_message(message) - - self.assertEqual(expect_webhook_call, send_webhook.called) - self.assertEqual(expect_attachment_call, send_attachments.called) - - message.add_reaction.assert_called_once_with(self.checkmark_emoji) - - @patch(f"{MODULE_PATH}.send_attachments", new_callable=AsyncMock) - async def test_relay_message_handles_irretrievable_attachment_exceptions(self, send_attachments): - """The `relay_message` method should handle irretrievable attachments.""" - message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) - side_effects = (discord.errors.Forbidden(MagicMock(), ""), discord.errors.NotFound(MagicMock(), "")) - - self.cog.webhook = helpers.MockAsyncWebhook() - log = logging.getLogger("bot.cogs.duck_pond") - - for side_effect in side_effects: # pragma: no cover - send_attachments.side_effect = side_effect - with patch(f"{MODULE_PATH}.send_webhook", new_callable=AsyncMock) as send_webhook: - with self.subTest(side_effect=type(side_effect).__name__): - with self.assertNotLogs(logger=log, level=logging.ERROR): - await self.cog.relay_message(message) - - self.assertEqual(send_webhook.call_count, 2) - - @patch(f"{MODULE_PATH}.send_webhook", new_callable=AsyncMock) - @patch(f"{MODULE_PATH}.send_attachments", new_callable=AsyncMock) - async def test_relay_message_handles_attachment_http_error(self, send_attachments, send_webhook): - """The `relay_message` method should handle irretrievable attachments.""" - message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) - - self.cog.webhook = helpers.MockAsyncWebhook() - log = logging.getLogger("bot.cogs.duck_pond") - - side_effect = discord.HTTPException(MagicMock(), "") - send_attachments.side_effect = side_effect - with self.subTest(side_effect=type(side_effect).__name__): - with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: - await self.cog.relay_message(message) - - send_webhook.assert_called_once_with( - webhook=self.cog.webhook, - content=message.clean_content, - username=message.author.display_name, - avatar_url=message.author.avatar_url - ) - - self.assertEqual(len(log_watcher.records), 1) - - record = log_watcher.records[0] - self.assertEqual(record.levelno, logging.ERROR) - - def _mock_payload(self, label: str, is_custom_emoji: bool, id_: int, emoji_name: str): - """Creates a mock `on_raw_reaction_add` payload with the specified emoji data.""" - payload = MagicMock(name=label) - payload.emoji.is_custom_emoji.return_value = is_custom_emoji - payload.emoji.id = id_ - payload.emoji.name = emoji_name - return payload - - async def test_payload_has_duckpond_emoji_correctly_detects_relevant_emojis(self): - """The `on_raw_reaction_add` event handler should ignore irrelevant emojis.""" - test_values = ( - # Custom Emojis - ( - self._mock_payload( - label="Custom Duckpond Emoji", - is_custom_emoji=True, - id_=constants.DuckPond.custom_emojis[0], - emoji_name="" - ), - True - ), - ( - self._mock_payload( - label="Custom Non-Duckpond Emoji", - is_custom_emoji=True, - id_=123, - emoji_name="" - ), - False - ), - # Unicode Emojis - ( - self._mock_payload( - label="Unicode Duck Emoji", - is_custom_emoji=False, - id_=1, - emoji_name=self.unicode_duck_emoji - ), - True - ), - ( - self._mock_payload( - label="Unicode Non-Duck Emoji", - is_custom_emoji=False, - id_=1, - emoji_name=self.thumbs_up_emoji - ), - False - ), - ) - - for payload, expected_return in test_values: - actual_return = self.cog._payload_has_duckpond_emoji(payload) - with self.subTest(case=payload._mock_name, expected_return=expected_return, actual_return=actual_return): - self.assertEqual(expected_return, actual_return) - - @patch(f"{MODULE_PATH}.discord.utils.get") - @patch(f"{MODULE_PATH}.DuckPond._payload_has_duckpond_emoji", new=MagicMock(return_value=False)) - def test_on_raw_reaction_add_returns_early_with_payload_without_duck_emoji(self, utils_get): - """The `on_raw_reaction_add` method should return early if the payload does not contain a duck emoji.""" - self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_add(payload=MagicMock()))) - - # Ensure we've returned before making an unnecessary API call in the lines of code after the emoji check - utils_get.assert_not_called() - - def _raw_reaction_mocks(self, channel_id, message_id, user_id): - """Sets up mocks for tests of the `on_raw_reaction_add` event listener.""" - channel = helpers.MockTextChannel(id=channel_id) - self.bot.get_all_channels.return_value = (channel,) - - message = helpers.MockMessage(id=message_id) - - channel.fetch_message.return_value = message - - member = helpers.MockMember(id=user_id, roles=[self.staff_role]) - message.guild.members = (member,) - - payload = MagicMock(channel_id=channel_id, message_id=message_id, user_id=user_id) - - return channel, message, member, payload - - async def test_on_raw_reaction_add_returns_for_bot_and_non_staff_members(self): - """The `on_raw_reaction_add` event handler should return for bot users or non-staff members.""" - channel_id = 1234 - message_id = 2345 - user_id = 3456 - - channel, message, _, payload = self._raw_reaction_mocks(channel_id, message_id, user_id) - - test_cases = ( - ("non-staff member", helpers.MockMember(id=user_id)), - ("bot staff member", helpers.MockMember(id=user_id, roles=[self.staff_role], bot=True)), - ) - - payload.emoji = self.duck_pond_emoji - - for description, member in test_cases: - message.guild.members = (member, ) - with self.subTest(test_case=description), patch(f"{MODULE_PATH}.DuckPond.has_green_checkmark") as checkmark: - checkmark.side_effect = AssertionError( - "Expected method to return before calling `self.has_green_checkmark`." - ) - self.assertIsNone(await self.cog.on_raw_reaction_add(payload)) - - # Check that we did make it past the payload checks - channel.fetch_message.assert_called_once() - channel.fetch_message.reset_mock() - - @patch(f"{MODULE_PATH}.DuckPond.is_staff") - @patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) - def test_on_raw_reaction_add_returns_on_message_with_green_checkmark_placed_by_bot(self, count_ducks, is_staff): - """The `on_raw_reaction_add` event should return when the message has a green check mark placed by the bot.""" - channel_id = 31415926535 - message_id = 27182818284 - user_id = 16180339887 - - channel, message, member, payload = self._raw_reaction_mocks(channel_id, message_id, user_id) - - payload.emoji = helpers.MockPartialEmoji(name=self.unicode_duck_emoji) - payload.emoji.is_custom_emoji.return_value = False - - message.reactions = [helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.bot.user])] - - is_staff.return_value = True - count_ducks.side_effect = AssertionError("Expected method to return before calling `self.count_ducks`") - - self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_add(payload))) - - # Assert that we've made it past `self.is_staff` - is_staff.assert_called_once() - - async def test_on_raw_reaction_add_does_not_relay_below_duck_threshold(self): - """The `on_raw_reaction_add` listener should not relay messages or attachments below the duck threshold.""" - test_cases = ( - (constants.DuckPond.threshold - 1, False), - (constants.DuckPond.threshold, True), - (constants.DuckPond.threshold + 1, True), - ) - - channel, message, member, payload = self._raw_reaction_mocks(channel_id=3, message_id=4, user_id=5) - - payload.emoji = self.duck_pond_emoji - - for duck_count, should_relay in test_cases: - with patch(f"{MODULE_PATH}.DuckPond.relay_message", new_callable=AsyncMock) as relay_message: - with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) as count_ducks: - count_ducks.return_value = duck_count - with self.subTest(duck_count=duck_count, should_relay=should_relay): - await self.cog.on_raw_reaction_add(payload) - - # Confirm that we've made it past counting - count_ducks.assert_called_once() - - # Did we relay a message? - has_relayed = relay_message.called - self.assertEqual(has_relayed, should_relay) - - if should_relay: - relay_message.assert_called_once_with(message) - - async def test_on_raw_reaction_remove_prevents_removal_of_green_checkmark_depending_on_the_duck_count(self): - """The `on_raw_reaction_remove` listener prevents removal of the check mark on messages with enough ducks.""" - checkmark = helpers.MockPartialEmoji(name=self.checkmark_emoji) - - message = helpers.MockMessage(id=1234) - - channel = helpers.MockTextChannel(id=98765) - channel.fetch_message.return_value = message - - self.bot.get_all_channels.return_value = (channel, ) - - payload = MagicMock(channel_id=channel.id, message_id=message.id, emoji=checkmark) - - test_cases = ( - (constants.DuckPond.threshold - 1, False), - (constants.DuckPond.threshold, True), - (constants.DuckPond.threshold + 1, True), - ) - for duck_count, should_re_add_checkmark in test_cases: - with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) as count_ducks: - count_ducks.return_value = duck_count - with self.subTest(duck_count=duck_count, should_re_add_checkmark=should_re_add_checkmark): - await self.cog.on_raw_reaction_remove(payload) - - # Check if we fetched the message - channel.fetch_message.assert_called_once_with(message.id) - - # Check if we actually counted the number of ducks - count_ducks.assert_called_once_with(message) - - has_re_added_checkmark = message.add_reaction.called - self.assertEqual(should_re_add_checkmark, has_re_added_checkmark) - - if should_re_add_checkmark: - message.add_reaction.assert_called_once_with(self.checkmark_emoji) - message.add_reaction.reset_mock() - - # reset mocks - channel.fetch_message.reset_mock() - message.reset_mock() - - def test_on_raw_reaction_remove_ignores_removal_of_non_checkmark_reactions(self): - """The `on_raw_reaction_remove` listener should ignore the removal of non-check mark emojis.""" - channel = helpers.MockTextChannel(id=98765) - - channel.fetch_message.side_effect = AssertionError( - "Expected method to return before calling `channel.fetch_message`" - ) - - self.bot.get_all_channels.return_value = (channel, ) - - payload = MagicMock(emoji=helpers.MockPartialEmoji(name=self.thumbs_up_emoji), channel_id=channel.id) - - self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_remove(payload))) - - channel.fetch_message.assert_not_called() - - -class DuckPondSetupTests(unittest.TestCase): - """Tests setup of the `DuckPond` cog.""" - - def test_setup(self): - """Setup of the extension should call add_cog.""" - bot = helpers.MockBot() - duck_pond.setup(bot) - bot.add_cog.assert_called_once() -- cgit v1.2.3 From 28bfbe65b3fe113948a027970eb7cb22f5b467b9 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sun, 20 Sep 2020 13:27:23 +0200 Subject: Use helper for duckpond's locked relay feature I've created a helper for duckpond's relay feature to allow me to use it separately from the command we're planning to add to the Cog. I opted not to include the lock in the original relay method to separate the logic more clearly. Signed-off-by: Sebastiaan Zeeff --- bot/cogs/duck_pond.py | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index 12f4cb7b8..6156c3238 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -105,6 +105,25 @@ class DuckPond(Cog): except discord.HTTPException: log.exception("Failed to send an attachment to the webhook") + async def locked_relay(self, message: discord.Message) -> bool: + """Relay a message after obtaining the relay lock.""" + if self.relay_lock is None: + # Lazily load the lock to ensure it's created within the + # appropriate event loop. + self.relay_lock = asyncio.Lock() + + async with self.relay_lock: + # check if the message has a checkmark after acquiring the lock + if await self.has_green_checkmark(message): + return False + + # relay the message + await self.relay_message(message) + + # add a green checkmark to indicate that the message was relayed + await message.add_reaction("✅") + return True + def _payload_has_duckpond_emoji(self, emoji: discord.PartialEmoji) -> bool: """Test if the RawReactionActionEvent payload contains a duckpond emoji.""" if emoji.is_unicode_emoji(): @@ -150,21 +169,7 @@ class DuckPond(Cog): # If we've got more than the required amount of ducks, send the message to the duck_pond. if duck_count >= constants.DuckPond.threshold: - if self.relay_lock is None: - # Lazily load the lock to ensure it's created within the - # appropriate event loop. - self.relay_lock = asyncio.Lock() - - async with self.relay_lock: - # check if the message has a checkmark after acquiring the lock - if await self.has_green_checkmark(message): - return - - # relay the message - await self.relay_message(message) - - # add a green checkmark to indicate that the message was relayed - await message.add_reaction("✅") + await self.locked_relay(message) @Cog.listener() async def on_raw_reaction_remove(self, payload: RawReactionActionEvent) -> None: -- cgit v1.2.3 From ef31ddad7a736ccea5662cd1e192d3663f587bd7 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sun, 20 Sep 2020 13:29:00 +0200 Subject: Add command to relay a message to duckpond This commit adds a command that allows admins to manually relay a message to the duckpond, regardless of duck counts and the checks done in the reaction event handler. Signed-off-by: Sebastiaan Zeeff --- bot/cogs/duck_pond.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index 6156c3238..2758de8ab 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -4,10 +4,11 @@ from typing import Union import discord from discord import Color, Embed, Member, Message, RawReactionActionEvent, User, errors -from discord.ext.commands import Cog +from discord.ext.commands import Cog, Context, command from bot import constants from bot.bot import Bot +from bot.decorators import with_role from bot.utils.messages import send_attachments from bot.utils.webhooks import send_webhook @@ -183,6 +184,15 @@ class DuckPond(Cog): if duck_count >= constants.DuckPond.threshold: await message.add_reaction("✅") + @command(name="duckify", aliases=("duckpond", "pondify")) + @with_role(constants.Roles.admins) + async def duckify(self, ctx: Context, message: discord.Message) -> None: + """Relay a message to the duckpond, no ducks required!""" + if await self.locked_relay(message): + await ctx.message.add_reaction("🦆") + else: + await ctx.message.add_reaction("❌") + def setup(bot: Bot) -> None: """Load the DuckPond cog.""" -- cgit v1.2.3 From 588213c6209b8df143634f916ff866bea4a9cec3 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sun, 20 Sep 2020 20:26:25 +0200 Subject: Lower duckpond threshold to increase activity There's not a lot of activity in our duckpond at the moment. To activate our duckies, I've decreased the duckpond threshold to 4. This means that a message will now be relayed once it's been ducked four times. Let's get all of our ducks in a row. Signed-off-by: Sebastiaan Zeeff --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index 4c1c7e483..fe15e5a87 100644 --- a/config-default.yml +++ b/config-default.yml @@ -463,7 +463,7 @@ sync: max_diff: 10 duck_pond: - threshold: 5 + threshold: 4 channel_blacklist: - *ANNOUNCEMENTS - *PYNEWS_CHANNEL -- cgit v1.2.3 From ecb132f9044b90f880d1d66ea19c99eb338b338e Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 20 Sep 2020 21:47:50 +0300 Subject: Remove special shortening from reason --- bot/cogs/moderation/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 4a3c14391..4dba8e812 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -157,7 +157,7 @@ async def notify_infraction( text = INFRACTION_DESCRIPTION_TEMPLATE.format( type=infr_type.capitalize(), expires=expires_at or "N/A", - reason=textwrap.shorten(reason, 1000, placeholder="...") if reason else "No reason provided." + reason=reason or "No reason provided." ) # For case when other fields than reason is too long and this reach limit, then force-shorten string -- cgit v1.2.3 From 5212cc6d00355e3376454c181d8f825cb2547ff1 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 20 Sep 2020 21:59:18 +0300 Subject: Remove useless textwrap import --- bot/cogs/moderation/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 4dba8e812..5ad7838f0 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -1,5 +1,4 @@ import logging -import textwrap import typing as t from datetime import datetime -- cgit v1.2.3 From c22561d2f527666def2e201e655f5ac767d95212 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 20 Sep 2020 22:08:16 +0300 Subject: Try to fix location from where post infraction test get ID --- tests/bot/cogs/moderation/test_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index c9a4e4040..02a18bbca 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -306,7 +306,7 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): """Should return response from POST request if there are no errors.""" now = datetime.now() payload = { - "actor": self.ctx.message.author.id, + "actor": self.ctx.author.id, "hidden": True, "reason": "Test reason", "type": "ban", @@ -344,7 +344,7 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): async def test_first_fail_second_success_user_post_infraction(self, post_user_mock): """Should post the user if they don't exist, POST infraction again, and return the response if successful.""" payload = { - "actor": self.ctx.message.author.id, + "actor": self.ctx.author.id, "hidden": False, "reason": "Test reason", "type": "mute", -- cgit v1.2.3 From a8b1c72d379d187b6266b6b38f9e85e594f39b11 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 20 Sep 2020 22:22:37 +0300 Subject: Apply recent changes of notify infraction to test --- tests/bot/cogs/moderation/test_utils.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/moderation/test_utils.py b/tests/bot/cogs/moderation/test_utils.py index 02a18bbca..5f649e136 100644 --- a/tests/bot/cogs/moderation/test_utils.py +++ b/tests/bot/cogs/moderation/test_utils.py @@ -1,4 +1,3 @@ -import textwrap import unittest from collections import namedtuple from datetime import datetime @@ -211,8 +210,8 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( type="Mute", expires="N/A", - reason=textwrap.shorten("foo bar" * 4000, 1000, placeholder="...") - ), + reason="foo bar" * 4000 + )[:2045] + "...", colour=Colours.soft_red, url=utils.RULES_URL ).set_author( -- cgit v1.2.3 From 681027b61663bcdff5b174aa3e06f34b54f05349 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Mon, 21 Sep 2020 19:21:13 +0530 Subject: refactor code to GET users from site endpoint `bot/users` with pagination Added method to recursively GET users if paginated and another method to parse URL and return endpoint and query parameters. --- bot/cogs/sync/syncers.py | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index f7ba811bc..156c32a15 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -4,6 +4,7 @@ import logging import typing as t from collections import namedtuple from functools import partial +from urllib.parse import parse_qsl, urlparse import discord from discord import Guild, HTTPException, Member, Message, Reaction, User @@ -287,7 +288,8 @@ class UserSyncer(Syncer): async def _get_diff(self, guild: Guild) -> _Diff: """Return the difference of users between the cache of `guild` and the database.""" log.trace("Getting the diff for users.") - users = await self.bot.api_client.get('bot/users') + + users = await self._get_users() # Pack DB roles and guild roles into one common, hashable format. # They're hashable so that they're easily comparable with sets later. @@ -336,6 +338,32 @@ class UserSyncer(Syncer): return _Diff(users_to_create, users_to_update, None) + async def _get_users(self, endpoint: str = "bot/users", query_params: dict = None) -> t.List[dict]: + """GET all users recursively.""" + users: list = [] + response: dict = await self.bot.api_client.get(endpoint, params=query_params) + users.extend(response["results"]) + + # The `response` is paginated, hence check if next page exists. + if (next_page_url := response["next"]) is not None: + next_endpoint, query_params = self.get_endpoint(next_page_url) + users.extend(await self._get_users(next_endpoint, query_params)) + + return users + + @staticmethod + def get_endpoint(url: str) -> tuple: + """Extract the API endpoint and query params from a URL.""" + url = urlparse(url) + + # Do not include starting `/` for endpoint. + endpoint = url.path[1:] + + # Query params. + params = parse_qsl(url.query) + + return endpoint, params + async def _sync(self, diff: _Diff) -> None: """Synchronise the database with the user cache of `guild`.""" log.trace("Syncing created users...") -- cgit v1.2.3 From e68fad590415479f7b53545bf942d9f3b25ad1d3 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 21 Sep 2020 20:41:47 +0300 Subject: Fix end of file of mod utils tests --- tests/bot/exts/moderation/infraction/test_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index 674993862..5f649e136 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -356,4 +356,4 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): actual = await utils.post_infraction(self.ctx, self.user, "mute", "Test reason") self.assertEqual(actual, "foo") self.bot.api_client.post.assert_has_awaits([call("bot/infractions", json=payload)] * 2) - post_user_mock.assert_awaited_once_with(self.ctx, self.user) \ No newline at end of file + post_user_mock.assert_awaited_once_with(self.ctx, self.user) -- cgit v1.2.3 From cebee6c45f54fab1ab965cc0c764d5f478fc4cdd Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 21 Sep 2020 20:52:02 +0300 Subject: Fix import path of mod utils --- tests/bot/exts/moderation/infraction/test_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index 5f649e136..412f4398e 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, call, patch from discord import Embed, Forbidden, HTTPException, NotFound from bot.api import ResponseCodeError -from bot.cogs.moderation import utils +from bot.exts.moderation.infraction import _utils as utils from bot.constants import Colours, Icons from tests.helpers import MockBot, MockContext, MockMember, MockUser -- cgit v1.2.3 From 82b6af9ef458cea71704c2aff0d2ef10e8b623be Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 21 Sep 2020 20:59:00 +0300 Subject: Fix import order of mod utils tests --- tests/bot/exts/moderation/infraction/test_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index 412f4398e..fbbe112de 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -6,8 +6,8 @@ from unittest.mock import AsyncMock, MagicMock, call, patch from discord import Embed, Forbidden, HTTPException, NotFound from bot.api import ResponseCodeError -from bot.exts.moderation.infraction import _utils as utils from bot.constants import Colours, Icons +from bot.exts.moderation.infraction import _utils as utils from tests.helpers import MockBot, MockContext, MockMember, MockUser -- cgit v1.2.3 From 1051877d921eb9542ae845e3463aa2eadb5257a8 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 21 Sep 2020 20:32:15 +0200 Subject: Upload output with codeblock escapes to pastebin The output can't be sent to discord, but it won't affect anything in the paste service and can safely be uploaded to it. --- bot/exts/utils/snekbox.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index 03bf454ac..b3baffba2 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -150,6 +150,7 @@ class Snekbox(Cog): output = output.replace(" Date: Mon, 21 Sep 2020 20:41:46 +0200 Subject: Accommodate new upload behaviour in tests --- tests/bot/exts/utils/test_snekbox.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/exts/utils/test_snekbox.py b/tests/bot/exts/utils/test_snekbox.py index c272a4756..40b2202aa 100644 --- a/tests/bot/exts/utils/test_snekbox.py +++ b/tests/bot/exts/utils/test_snekbox.py @@ -117,12 +117,12 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): (' Date: Mon, 21 Sep 2020 21:45:27 +0300 Subject: Fix mod utils tests patch locations --- tests/bot/exts/moderation/infraction/test_utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index fbbe112de..5b62463e0 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -123,7 +123,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): else: self.ctx.send.assert_not_awaited() - @patch("bot.cogs.moderation.utils.send_private_embed") + @patch("bot.exts.moderation.infraction._utils.send_private_embed") async def test_notify_infraction(self, send_private_embed_mock): """ Should send an embed of a certain format as a DM and return `True` if DM successful. @@ -238,7 +238,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): send_private_embed_mock.assert_awaited_once_with(case["args"][0], embed) - @patch("bot.cogs.moderation.utils.send_private_embed") + @patch("bot.exts.moderation.infraction._utils.send_private_embed") async def test_notify_pardon(self, send_private_embed_mock): """Should send an embed of a certain format as a DM and return `True` if DM successful.""" test_case = namedtuple("test_case", ["args", "icon", "send_result"]) @@ -330,7 +330,7 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): self.assertTrue("500" in self.ctx.send.call_args[0][0]) - @patch("bot.cogs.moderation.utils.post_user", return_value=None) + @patch("bot.exts.moderation.infraction._utils.post_user", return_value=None) async def test_user_not_found_none_post_infraction(self, post_user_mock): """Should abort and return `None` when a new user fails to be posted.""" self.bot.api_client.post.side_effect = ResponseCodeError(MagicMock(status=400), {"user": "foo"}) @@ -339,7 +339,7 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): self.assertIsNone(actual) post_user_mock.assert_awaited_once_with(self.ctx, self.user) - @patch("bot.cogs.moderation.utils.post_user", return_value="bar") + @patch("bot.exts.moderation.infraction._utils.post_user", return_value="bar") async def test_first_fail_second_success_user_post_infraction(self, post_user_mock): """Should post the user if they don't exist, POST infraction again, and return the response if successful.""" payload = { -- cgit v1.2.3 From 90c3c3bddd17a7b5ec5907041cc401f8685a502b Mon Sep 17 00:00:00 2001 From: Xithrius Date: Mon, 21 Sep 2020 17:29:10 -0700 Subject: Updated dependencies to include aioping. --- Pipfile | 1 + Pipfile.lock | 246 ++++++++++++++++++++++++++++++++--------------------------- 2 files changed, 133 insertions(+), 114 deletions(-) diff --git a/Pipfile b/Pipfile index 6fff2223e..0c8ba1380 100644 --- a/Pipfile +++ b/Pipfile @@ -7,6 +7,7 @@ name = "pypi" aio-pika = "~=6.1" aiodns = "~=2.0" aiohttp = "~=3.5" +aioping = "~=0.3.1" aioredis = "~=1.3.1" beautifulsoup4 = "~=4.9" colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"} diff --git a/Pipfile.lock b/Pipfile.lock index 50ddd478c..1f8ff4830 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "1905fd7eb15074ddbf04f2177b6cdd65edc4c74cb5fcbf4e6ca08ef649ba8a3c" + "sha256": "0dfb214d61424e266f95666076d13e959dd9ebc049e94d5d676d00aa5438de70" }, "pipfile-spec": 6, "requires": { @@ -18,11 +18,11 @@ "default": { "aio-pika": { "hashes": [ - "sha256:c4cbbeb85b3c7bf81bc127371846cd949e6231717ce1e6ac7ee1dd5ede21f866", - "sha256:ec7fef24f588d90314873463ab4f2c3debce0bd8830e49e3786586be96bc2e8e" + "sha256:4a20d4d941e1f113a950ea529a90bd9159c8d7aafaa1c71e9c707c8c2b526ea6", + "sha256:7bf3f183df1eb348d007210a0c1a3c5c755f1b3def1a9a395e93f30b91da1daf" ], "index": "pypi", - "version": "==6.6.1" + "version": "==6.7.0" }, "aiodns": { "hashes": [ @@ -50,6 +50,14 @@ "index": "pypi", "version": "==3.6.2" }, + "aioping": { + "hashes": [ + "sha256:8900ef2f5a589ba0c12aaa9c2d586f5371820d468d21b374ddb47ef5fc8f297c", + "sha256:f983d86acab3a04c322731ce88d42c55d04d2842565fc8532fe10c838abfd275" + ], + "index": "pypi", + "version": "==0.3.1" + }, "aioredis": { "hashes": [ "sha256:15f8af30b044c771aee6787e5ec24694c048184c7b9e54c3b60c750a4b93273a", @@ -83,11 +91,11 @@ }, "attrs": { "hashes": [ - "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", - "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" + "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594", + "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==19.3.0" + "version": "==20.2.0" }, "babel": { "hashes": [ @@ -115,36 +123,44 @@ }, "cffi": { "hashes": [ - "sha256:267adcf6e68d77ba154334a3e4fc921b8e63cbb38ca00d33d40655d4228502bc", - "sha256:26f33e8f6a70c255767e3c3f957ccafc7f1f706b966e110b855bfe944511f1f9", - "sha256:3cd2c044517f38d1b577f05927fb9729d3396f1d44d0c659a445599e79519792", - "sha256:4a03416915b82b81af5502459a8a9dd62a3c299b295dcdf470877cb948d655f2", - "sha256:4ce1e995aeecf7cc32380bc11598bfdfa017d592259d5da00fc7ded11e61d022", - "sha256:4f53e4128c81ca3212ff4cf097c797ab44646a40b42ec02a891155cd7a2ba4d8", - "sha256:4fa72a52a906425416f41738728268072d5acfd48cbe7796af07a923236bcf96", - "sha256:66dd45eb9530e3dde8f7c009f84568bc7cac489b93d04ac86e3111fb46e470c2", - "sha256:6923d077d9ae9e8bacbdb1c07ae78405a9306c8fd1af13bfa06ca891095eb995", - "sha256:833401b15de1bb92791d7b6fb353d4af60dc688eaa521bd97203dcd2d124a7c1", - "sha256:8416ed88ddc057bab0526d4e4e9f3660f614ac2394b5e019a628cdfff3733849", - "sha256:892daa86384994fdf4856cb43c93f40cbe80f7f95bb5da94971b39c7f54b3a9c", - "sha256:98be759efdb5e5fa161e46d404f4e0ce388e72fbf7d9baf010aff16689e22abe", - "sha256:a6d28e7f14ecf3b2ad67c4f106841218c8ab12a0683b1528534a6c87d2307af3", - "sha256:b1d6ebc891607e71fd9da71688fcf332a6630b7f5b7f5549e6e631821c0e5d90", - "sha256:b2a2b0d276a136146e012154baefaea2758ef1f56ae9f4e01c612b0831e0bd2f", - "sha256:b87dfa9f10a470eee7f24234a37d1d5f51e5f5fa9eeffda7c282e2b8f5162eb1", - "sha256:bac0d6f7728a9cc3c1e06d4fcbac12aaa70e9379b3025b27ec1226f0e2d404cf", - "sha256:c991112622baee0ae4d55c008380c32ecfd0ad417bcd0417ba432e6ba7328caa", - "sha256:cda422d54ee7905bfc53ee6915ab68fe7b230cacf581110df4272ee10462aadc", - "sha256:d3148b6ba3923c5850ea197a91a42683f946dba7e8eb82dfa211ab7e708de939", - "sha256:d6033b4ffa34ef70f0b8086fd4c3df4bf801fee485a8a7d4519399818351aa8e", - "sha256:ddff0b2bd7edcc8c82d1adde6dbbf5e60d57ce985402541cd2985c27f7bec2a0", - "sha256:e23cb7f1d8e0f93addf0cae3c5b6f00324cccb4a7949ee558d7b6ca973ab8ae9", - "sha256:effd2ba52cee4ceff1a77f20d2a9f9bf8d50353c854a282b8760ac15b9833168", - "sha256:f90c2267101010de42f7273c94a1f026e56cbc043f9330acd8a80e64300aba33", - "sha256:f960375e9823ae6a07072ff7f8a85954e5a6434f97869f50d0e41649a1c8144f", - "sha256:fcf32bf76dc25e30ed793145a57426064520890d7c02866eb93d3e4abe516948" - ], - "version": "==1.14.1" + "sha256:005f2bfe11b6745d726dbb07ace4d53f057de66e336ff92d61b8c7e9c8f4777d", + "sha256:09e96138280241bd355cd585148dec04dbbedb4f46128f340d696eaafc82dd7b", + "sha256:0b1ad452cc824665ddc682400b62c9e4f5b64736a2ba99110712fdee5f2505c4", + "sha256:0ef488305fdce2580c8b2708f22d7785ae222d9825d3094ab073e22e93dfe51f", + "sha256:15f351bed09897fbda218e4db5a3d5c06328862f6198d4fb385f3e14e19decb3", + "sha256:22399ff4870fb4c7ef19fff6eeb20a8bbf15571913c181c78cb361024d574579", + "sha256:23e5d2040367322824605bc29ae8ee9175200b92cb5483ac7d466927a9b3d537", + "sha256:2791f68edc5749024b4722500e86303a10d342527e1e3bcac47f35fbd25b764e", + "sha256:2f9674623ca39c9ebe38afa3da402e9326c245f0f5ceff0623dccdac15023e05", + "sha256:3363e77a6176afb8823b6e06db78c46dbc4c7813b00a41300a4873b6ba63b171", + "sha256:33c6cdc071ba5cd6d96769c8969a0531be2d08c2628a0143a10a7dcffa9719ca", + "sha256:3b8eaf915ddc0709779889c472e553f0d3e8b7bdf62dab764c8921b09bf94522", + "sha256:3cb3e1b9ec43256c4e0f8d2837267a70b0e1ca8c4f456685508ae6106b1f504c", + "sha256:3eeeb0405fd145e714f7633a5173318bd88d8bbfc3dd0a5751f8c4f70ae629bc", + "sha256:44f60519595eaca110f248e5017363d751b12782a6f2bd6a7041cba275215f5d", + "sha256:4d7c26bfc1ea9f92084a1d75e11999e97b62d63128bcc90c3624d07813c52808", + "sha256:529c4ed2e10437c205f38f3691a68be66c39197d01062618c55f74294a4a4828", + "sha256:6642f15ad963b5092d65aed022d033c77763515fdc07095208f15d3563003869", + "sha256:85ba797e1de5b48aa5a8427b6ba62cf69607c18c5d4eb747604b7302f1ec382d", + "sha256:8f0f1e499e4000c4c347a124fa6a27d37608ced4fe9f7d45070563b7c4c370c9", + "sha256:a624fae282e81ad2e4871bdb767e2c914d0539708c0f078b5b355258293c98b0", + "sha256:b0358e6fefc74a16f745afa366acc89f979040e0cbc4eec55ab26ad1f6a9bfbc", + "sha256:bbd2f4dfee1079f76943767fce837ade3087b578aeb9f69aec7857d5bf25db15", + "sha256:bf39a9e19ce7298f1bd6a9758fa99707e9e5b1ebe5e90f2c3913a47bc548747c", + "sha256:c11579638288e53fc94ad60022ff1b67865363e730ee41ad5e6f0a17188b327a", + "sha256:c150eaa3dadbb2b5339675b88d4573c1be3cb6f2c33a6c83387e10cc0bf05bd3", + "sha256:c53af463f4a40de78c58b8b2710ade243c81cbca641e34debf3396a9640d6ec1", + "sha256:cb763ceceae04803adcc4e2d80d611ef201c73da32d8f2722e9d0ab0c7f10768", + "sha256:cc75f58cdaf043fe6a7a6c04b3b5a0e694c6a9e24050967747251fb80d7bce0d", + "sha256:d80998ed59176e8cba74028762fbd9b9153b9afc71ea118e63bbf5d4d0f9552b", + "sha256:de31b5164d44ef4943db155b3e8e17929707cac1e5bd2f363e67a56e3af4af6e", + "sha256:e66399cf0fc07de4dce4f588fc25bfe84a6d1285cc544e67987d22663393926d", + "sha256:f0620511387790860b249b9241c2f13c3a80e21a73e0b861a2df24e9d6f56730", + "sha256:f4eae045e6ab2bb54ca279733fe4eb85f1effda392666308250714e01907f394", + "sha256:f92cdecb618e5fa4658aeb97d5eb3d2f47aa94ac6477c6daf0f306c5a3b9e6b1", + "sha256:f92f789e4f9241cd262ad7a555ca2c648a98178a953af117ef7fad46aa1d5591" + ], + "version": "==1.14.3" }, "chardet": { "hashes": [ @@ -188,11 +204,11 @@ }, "discord.py": { "hashes": [ - "sha256:2b1846bfa382b54f4eace8e437a9f59f185388c5b08749ac0e1bbd98e05bfde5", - "sha256:f3db9531fccc391f51de65cfa46133106a9ba12ff2927aca6c14bffd3b7f17b5" + "sha256:98ea3096a3585c9c379209926f530808f5fcf4930928d8cfb579d2562d119570", + "sha256:f9decb3bfa94613d922376288617e6a6f969260923643e2897f4540c34793442" ], "markers": "python_full_version >= '3.5.3'", - "version": "==1.4.0" + "version": "==1.4.1" }, "docutils": { "hashes": [ @@ -204,11 +220,11 @@ }, "fakeredis": { "hashes": [ - "sha256:790c85ad0f3b2967aba1f51767021bc59760fcb612159584be018ea7384f7fd2", - "sha256:fdfe06f277092d022c271fcaefdc1f0c8d9bfa8cb15374cae41d66a20bd96d2b" + "sha256:7ea0866ba5edb40fe2e9b1722535df0c7e6b91d518aa5f50d96c2fff3ea7f4c2", + "sha256:aad8836ffe0319ffbba66dcf872ac6e7e32d1f19790e31296ba58445efb0a5c7" ], "index": "pypi", - "version": "==1.4.2" + "version": "==1.4.3" }, "feedparser": { "hashes": [ @@ -350,10 +366,11 @@ }, "markdownify": { "hashes": [ - "sha256:28ce67d1888e4908faaab7b04d2193cda70ea4f902f156a21d0aaea55e63e0a1" + "sha256:30be8340724e706c9e811c27fe8c1542cf74a15b46827924fff5c54b40dd9b0d", + "sha256:a69588194fd76634f0139d6801b820fd652dc5eeba9530e90d323dfdc0155252" ], "index": "pypi", - "version": "==0.4.1" + "version": "==0.5.3" }, "markupsafe": { "hashes": [ @@ -396,11 +413,11 @@ }, "more-itertools": { "hashes": [ - "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5", - "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2" + "sha256:6f83822ae94818eae2612063a5101a7311e68ae8002005b5e05f03fd74a86a20", + "sha256:9b30f12df9393f0d28af9210ff8efe48d10c94f73e5daf886f10c4b0b0b4f03c" ], "index": "pypi", - "version": "==8.4.0" + "version": "==8.5.0" }, "multidict": { "hashes": [ @@ -491,11 +508,11 @@ }, "pygments": { "hashes": [ - "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44", - "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324" + "sha256:307543fe65c0947b126e83dd5a61bd8acbd84abec11f43caebaf5534cbc17998", + "sha256:926c3f319eda178d1bd90851e4317e6d8cdb5e292a3386aac9bd75eca29cf9c7" ], "markers": "python_version >= '3.5'", - "version": "==2.6.1" + "version": "==2.7.1" }, "pyparsing": { "hashes": [ @@ -555,11 +572,11 @@ }, "sentry-sdk": { "hashes": [ - "sha256:21b17d6aa064c0fb703a7c00f77cf6c9c497cf2f83345c28892980a5e742d116", - "sha256:4fc97114c77d005467b9b1a29f042e2bc01923cb683b0ef0bbda46e79fa12532" + "sha256:1a086486ff9da15791f294f6e9915eb3747d161ef64dee2d038a4d0b4a369b24", + "sha256:45486deb031cea6bbb25a540d7adb4dd48cd8a1cc31e6a5ce9fb4f792a572e9a" ], "index": "pypi", - "version": "==0.16.3" + "version": "==0.17.6" }, "six": { "hashes": [ @@ -697,11 +714,11 @@ }, "attrs": { "hashes": [ - "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", - "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" + "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594", + "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==19.3.0" + "version": "==20.2.0" }, "cfgv": { "hashes": [ @@ -713,43 +730,43 @@ }, "coverage": { "hashes": [ - "sha256:098a703d913be6fbd146a8c50cc76513d726b022d170e5e98dc56d958fd592fb", - "sha256:16042dc7f8e632e0dcd5206a5095ebd18cb1d005f4c89694f7f8aafd96dd43a3", - "sha256:1adb6be0dcef0cf9434619d3b892772fdb48e793300f9d762e480e043bd8e716", - "sha256:27ca5a2bc04d68f0776f2cdcb8bbd508bbe430a7bf9c02315cd05fb1d86d0034", - "sha256:28f42dc5172ebdc32622a2c3f7ead1b836cdbf253569ae5673f499e35db0bac3", - "sha256:2fcc8b58953d74d199a1a4d633df8146f0ac36c4e720b4a1997e9b6327af43a8", - "sha256:304fbe451698373dc6653772c72c5d5e883a4aadaf20343592a7abb2e643dae0", - "sha256:30bc103587e0d3df9e52cd9da1dd915265a22fad0b72afe54daf840c984b564f", - "sha256:40f70f81be4d34f8d491e55936904db5c527b0711b2a46513641a5729783c2e4", - "sha256:4186fc95c9febeab5681bc3248553d5ec8c2999b8424d4fc3a39c9cba5796962", - "sha256:46794c815e56f1431c66d81943fa90721bb858375fb36e5903697d5eef88627d", - "sha256:4869ab1c1ed33953bb2433ce7b894a28d724b7aa76c19b11e2878034a4e4680b", - "sha256:4f6428b55d2916a69f8d6453e48a505c07b2245653b0aa9f0dee38785939f5e4", - "sha256:52f185ffd3291196dc1aae506b42e178a592b0b60a8610b108e6ad892cfc1bb3", - "sha256:538f2fd5eb64366f37c97fdb3077d665fa946d2b6d95447622292f38407f9258", - "sha256:64c4f340338c68c463f1b56e3f2f0423f7b17ba6c3febae80b81f0e093077f59", - "sha256:675192fca634f0df69af3493a48224f211f8db4e84452b08d5fcebb9167adb01", - "sha256:700997b77cfab016533b3e7dbc03b71d33ee4df1d79f2463a318ca0263fc29dd", - "sha256:8505e614c983834239f865da2dd336dcf9d72776b951d5dfa5ac36b987726e1b", - "sha256:962c44070c281d86398aeb8f64e1bf37816a4dfc6f4c0f114756b14fc575621d", - "sha256:9e536783a5acee79a9b308be97d3952b662748c4037b6a24cbb339dc7ed8eb89", - "sha256:9ea749fd447ce7fb1ac71f7616371f04054d969d412d37611716721931e36efd", - "sha256:a34cb28e0747ea15e82d13e14de606747e9e484fb28d63c999483f5d5188e89b", - "sha256:a3ee9c793ffefe2944d3a2bd928a0e436cd0ac2d9e3723152d6fd5398838ce7d", - "sha256:aab75d99f3f2874733946a7648ce87a50019eb90baef931698f96b76b6769a46", - "sha256:b1ed2bdb27b4c9fc87058a1cb751c4df8752002143ed393899edb82b131e0546", - "sha256:b360d8fd88d2bad01cb953d81fd2edd4be539df7bfec41e8753fe9f4456a5082", - "sha256:b8f58c7db64d8f27078cbf2a4391af6aa4e4767cc08b37555c4ae064b8558d9b", - "sha256:c1bbb628ed5192124889b51204de27c575b3ffc05a5a91307e7640eff1d48da4", - "sha256:c2ff24df02a125b7b346c4c9078c8936da06964cc2d276292c357d64378158f8", - "sha256:c890728a93fffd0407d7d37c1e6083ff3f9f211c83b4316fae3778417eab9811", - "sha256:c96472b8ca5dc135fb0aa62f79b033f02aa434fb03a8b190600a5ae4102df1fd", - "sha256:ce7866f29d3025b5b34c2e944e66ebef0d92e4a4f2463f7266daa03a1332a651", - "sha256:e26c993bd4b220429d4ec8c1468eca445a4064a61c74ca08da7429af9bc53bb0" - ], - "index": "pypi", - "version": "==5.2.1" + "sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516", + "sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259", + "sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9", + "sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097", + "sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0", + "sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f", + "sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7", + "sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c", + "sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5", + "sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7", + "sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729", + "sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978", + "sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9", + "sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f", + "sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9", + "sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822", + "sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418", + "sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82", + "sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f", + "sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d", + "sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221", + "sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4", + "sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21", + "sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709", + "sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54", + "sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d", + "sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270", + "sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24", + "sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751", + "sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a", + "sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237", + "sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7", + "sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636", + "sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8" + ], + "index": "pypi", + "version": "==5.3" }, "distlib": { "hashes": [ @@ -775,11 +792,11 @@ }, "flake8-annotations": { "hashes": [ - "sha256:7816a5d8f65ffdf37b8e21e5b17e0fd1e492aa92638573276de066e889a22b26", - "sha256:8d18db74a750dd97f40b483cc3ef80d07d03f687525bad8fd83365dcd3bfd414" + "sha256:09fe1aa3f40cb8fef632a0ab3614050a7584bb884b6134e70cf1fc9eeee642fa", + "sha256:5bda552f074fd6e34276c7761756fa07d824ffac91ce9c0a8555eb2bc5b92d7a" ], "index": "pypi", - "version": "==2.3.0" + "version": "==2.4.0" }, "flake8-bugbear": { "hashes": [ @@ -837,11 +854,11 @@ }, "identify": { "hashes": [ - "sha256:110ed090fec6bce1aabe3c72d9258a9de82207adeaa5a05cd75c635880312f9a", - "sha256:ccd88716b890ecbe10920659450a635d2d25de499b9a638525a48b48261d989b" + "sha256:d7da7de6825568daa4449858ce328ecc0e1ada2554d972a6f4f90e736aaf499a", + "sha256:e4db4796b3b0cf4f9cb921da51430abffff2d4ba7d7c521184ed5252bd90d461" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.4.25" + "version": "==1.5.4" }, "mccabe": { "hashes": [ @@ -852,9 +869,10 @@ }, "nodeenv": { "hashes": [ - "sha256:4b0b77afa3ba9b54f4b6396e60b0c83f59eaeb2d63dc3cc7a70f7f4af96c82bc" + "sha256:5304d424c529c997bc888453aeaa6362d242b6b4631e90f3d4bf1b290f1c84a9", + "sha256:ab45090ae383b716c4ef89e690c41ff8c2b257b85b309f01f3654df3d084bd7c" ], - "version": "==1.4.0" + "version": "==1.5.0" }, "pep8-naming": { "hashes": [ @@ -866,11 +884,11 @@ }, "pre-commit": { "hashes": [ - "sha256:1657663fdd63a321a4a739915d7d03baedd555b25054449090f97bb0cb30a915", - "sha256:e8b1315c585052e729ab7e99dcca5698266bedce9067d21dc909c23e3ceed626" + "sha256:810aef2a2ba4f31eed1941fc270e72696a1ad5590b9751839c90807d0fff6b9a", + "sha256:c54fd3e574565fe128ecc5e7d2f91279772ddb03f8729645fa812fe809084a70" ], "index": "pypi", - "version": "==2.6.0" + "version": "==2.7.1" }, "pycodestyle": { "hashes": [ @@ -882,11 +900,11 @@ }, "pydocstyle": { "hashes": [ - "sha256:da7831660b7355307b32778c4a0dbfb137d89254ef31a2b2978f50fc0b4d7586", - "sha256:f4f5d210610c2d153fae39093d44224c17429e2ad7da12a8b419aba5c2f614b5" + "sha256:19b86fa8617ed916776a11cd8bc0197e5b9856d5433b777f51a3defe13075325", + "sha256:aca749e190a01726a4fb472dd4ef23b5c9da7b9205c0a7857c06533de13fd678" ], "markers": "python_version >= '3.5'", - "version": "==5.0.2" + "version": "==5.1.1" }, "pyflakes": { "hashes": [ @@ -937,19 +955,19 @@ }, "unittest-xml-reporting": { "hashes": [ - "sha256:74eaf7739a7957a74f52b8187c5616f61157372189bef0a32ba5c30bbc00e58a", - "sha256:e09b8ae70cce9904cdd331f53bf929150962869a5324ab7ff3dd6c8b87e01f7d" + "sha256:7bf515ea8cb244255a25100cd29db611a73f8d3d0aaf672ed3266307e14cc1ca", + "sha256:984cebba69e889401bfe3adb9088ca376b3a1f923f0590d005126c1bffd1a695" ], "index": "pypi", - "version": "==3.0.2" + "version": "==3.0.4" }, "virtualenv": { "hashes": [ - "sha256:7b54fd606a1b85f83de49ad8d80dbec08e983a2d2f96685045b262ebc7481ee5", - "sha256:8cd7b2a4850b003a11be2fc213e206419efab41115cc14bca20e69654f2ac08e" + "sha256:43add625c53c596d38f971a465553f6318decc39d98512bc100fa1b1e839c8dc", + "sha256:e0305af10299a7fb0d69393d8f04cb2965dda9351140d11ac8db4e5e3970451b" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.0.30" + "version": "==20.0.31" } } } -- cgit v1.2.3 From 9853de0e3c83f3a95d5741d1e86e0266a08b4ea6 Mon Sep 17 00:00:00 2001 From: Xithrius Date: Mon, 21 Sep 2020 17:33:53 -0700 Subject: Created the Latency cog to measure ping in milliseconds. --- bot/exts/utils/ping.py | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 bot/exts/utils/ping.py diff --git a/bot/exts/utils/ping.py b/bot/exts/utils/ping.py new file mode 100644 index 000000000..14b26506a --- /dev/null +++ b/bot/exts/utils/ping.py @@ -0,0 +1,57 @@ +import socket +from datetime import datetime + +import aioping +from discord import Embed +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Emojis, URLs + +DESCRIPTIONS = ( + "Time to receive command information", + "Python Discord website latency", + "Discord API latency" +) +ROUND_LATENCY = 3 + + +class Latency(commands.Cog): + """Getting the latency between the bot and websites""" + + def __init__(self, bot: Bot) -> None: + self.bot = bot + + @commands.command() + async def ping(self, ctx: commands.Context) -> None: + """ + Gets different measures of latency within the bot. + + Returns bot, Python Discord Site, Discord Protocol latency. + """ + # datetime.datetime objects do not have the "milliseconds" attribute. + # It must be converted to microseconds before converting to milliseconds. + bot_ping = (datetime.utcnow() - ctx.message.created_at).total_seconds() / 1000 + bot_ping = f"{round(bot_ping, ROUND_LATENCY)} ms" + + try: + delay = await aioping.ping(URLs.site, family=socket.AddressFamily.AF_INET) * 1000 + site_ping = f"{round(delay, ROUND_LATENCY)} ms" + + except TimeoutError: + site_ping = f"{Emojis.cross_mark} Connection timed out." + + # Discord Protocol latency return value is in seconds, must be multiplied by 1000 to get milliseconds. + discord_ping = f"{round(self.bot.latency * 1000, ROUND_LATENCY)} ms" + + embed = Embed(title="Pong!") + + for desc, latency in zip(DESCRIPTIONS, [bot_ping, site_ping, discord_ping]): + embed.add_field(name=desc, value=latency, inline=False) + + await ctx.send(embed=embed) + + +def setup(bot: Bot) -> None: + """Load the Latency cog.""" + bot.add_cog(Latency(bot)) -- cgit v1.2.3 From 65f8fb3cca197d19220492a31688093531b065cb Mon Sep 17 00:00:00 2001 From: Xithrius Date: Mon, 21 Sep 2020 17:34:15 -0700 Subject: Added period to docstring. --- bot/exts/utils/ping.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/ping.py b/bot/exts/utils/ping.py index 14b26506a..c07a21bb7 100644 --- a/bot/exts/utils/ping.py +++ b/bot/exts/utils/ping.py @@ -17,7 +17,7 @@ ROUND_LATENCY = 3 class Latency(commands.Cog): - """Getting the latency between the bot and websites""" + """Getting the latency between the bot and websites.""" def __init__(self, bot: Bot) -> None: self.bot = bot -- cgit v1.2.3 From 427a2dc03d81de4aea0dce5e270226b1ec940b00 Mon Sep 17 00:00:00 2001 From: Xithrius Date: Mon, 21 Sep 2020 17:46:07 -0700 Subject: Description renamed to avoid verbosity. --- bot/exts/utils/ping.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/ping.py b/bot/exts/utils/ping.py index c07a21bb7..f2faa11ca 100644 --- a/bot/exts/utils/ping.py +++ b/bot/exts/utils/ping.py @@ -9,7 +9,7 @@ from bot.bot import Bot from bot.constants import Emojis, URLs DESCRIPTIONS = ( - "Time to receive command information", + "Command processing time", "Python Discord website latency", "Discord API latency" ) -- cgit v1.2.3 From 00f4e909070adb7916ff25cf8c772c404a50d329 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 21 Sep 2020 20:54:56 -0400 Subject: Use `has_any_role` decorator instead of old `with_role` decorator --- bot/exts/fun/duck_pond.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index 2758de8ab..6c2d22b9c 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -8,7 +8,7 @@ from discord.ext.commands import Cog, Context, command from bot import constants from bot.bot import Bot -from bot.decorators import with_role +from bot.utils.checks import has_any_role from bot.utils.messages import send_attachments from bot.utils.webhooks import send_webhook @@ -185,7 +185,7 @@ class DuckPond(Cog): await message.add_reaction("✅") @command(name="duckify", aliases=("duckpond", "pondify")) - @with_role(constants.Roles.admins) + @has_any_role(constants.Roles.admins) async def duckify(self, ctx: Context, message: discord.Message) -> None: """Relay a message to the duckpond, no ducks required!""" if await self.locked_relay(message): -- cgit v1.2.3 From b8747d69568feff3900cd4ffda2755d9f101c65f Mon Sep 17 00:00:00 2001 From: Xithrius <15021300+Xithrius@users.noreply.github.com> Date: Mon, 21 Sep 2020 19:20:26 -0700 Subject: Comment updated from "microseconds" to "seconds" Co-authored-by: Dennis Pham --- bot/exts/utils/ping.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/ping.py b/bot/exts/utils/ping.py index f2faa11ca..f26817159 100644 --- a/bot/exts/utils/ping.py +++ b/bot/exts/utils/ping.py @@ -30,7 +30,7 @@ class Latency(commands.Cog): Returns bot, Python Discord Site, Discord Protocol latency. """ # datetime.datetime objects do not have the "milliseconds" attribute. - # It must be converted to microseconds before converting to milliseconds. + # It must be converted to seconds before converting to milliseconds. bot_ping = (datetime.utcnow() - ctx.message.created_at).total_seconds() / 1000 bot_ping = f"{round(bot_ping, ROUND_LATENCY)} ms" -- cgit v1.2.3 From 912bfe00dc8edbb3795dc93485a8a871ccf9997c Mon Sep 17 00:00:00 2001 From: Xithrius <15021300+Xithrius@users.noreply.github.com> Date: Mon, 21 Sep 2020 19:21:36 -0700 Subject: Replacing the round function with a format specifier. Co-authored-by: Dennis Pham --- bot/exts/utils/ping.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/ping.py b/bot/exts/utils/ping.py index f26817159..ea67a7b16 100644 --- a/bot/exts/utils/ping.py +++ b/bot/exts/utils/ping.py @@ -32,7 +32,7 @@ class Latency(commands.Cog): # datetime.datetime objects do not have the "milliseconds" attribute. # It must be converted to seconds before converting to milliseconds. bot_ping = (datetime.utcnow() - ctx.message.created_at).total_seconds() / 1000 - bot_ping = f"{round(bot_ping, ROUND_LATENCY)} ms" + bot_ping = f"{bot_ping:.{ROUND_LATENCY}f} ms" try: delay = await aioping.ping(URLs.site, family=socket.AddressFamily.AF_INET) * 1000 -- cgit v1.2.3 From 7ce1c29402e4cc5aff5cc3ebe81f5477877fdb52 Mon Sep 17 00:00:00 2001 From: Xithrius <15021300+Xithrius@users.noreply.github.com> Date: Mon, 21 Sep 2020 19:22:06 -0700 Subject: Replacing the round function with a format specifier. Co-authored-by: Dennis Pham --- bot/exts/utils/ping.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/ping.py b/bot/exts/utils/ping.py index ea67a7b16..608b6c22c 100644 --- a/bot/exts/utils/ping.py +++ b/bot/exts/utils/ping.py @@ -36,7 +36,7 @@ class Latency(commands.Cog): try: delay = await aioping.ping(URLs.site, family=socket.AddressFamily.AF_INET) * 1000 - site_ping = f"{round(delay, ROUND_LATENCY)} ms" + site_ping = f"{delay:.{ROUND_LATENCY}f} ms" except TimeoutError: site_ping = f"{Emojis.cross_mark} Connection timed out." -- cgit v1.2.3 From 0fee151b4c0ec96ed49913ef5254eaf86d92cbcb Mon Sep 17 00:00:00 2001 From: Xithrius <15021300+Xithrius@users.noreply.github.com> Date: Mon, 21 Sep 2020 19:22:25 -0700 Subject: Replacing the round function with a format specifier. Co-authored-by: Dennis Pham --- bot/exts/utils/ping.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/ping.py b/bot/exts/utils/ping.py index 608b6c22c..e19a2c099 100644 --- a/bot/exts/utils/ping.py +++ b/bot/exts/utils/ping.py @@ -42,7 +42,7 @@ class Latency(commands.Cog): site_ping = f"{Emojis.cross_mark} Connection timed out." # Discord Protocol latency return value is in seconds, must be multiplied by 1000 to get milliseconds. - discord_ping = f"{round(self.bot.latency * 1000, ROUND_LATENCY)} ms" + discord_ping = f"{self.bot.latency * 1000:.{ROUND_LATENCY}f} ms" embed = Embed(title="Pong!") -- cgit v1.2.3 From ec0db2dd98e55f8bf5ba1c07375e196933129f99 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Tue, 22 Sep 2020 21:55:09 +0530 Subject: Refactor code to make use of bulk create and update API endpoints. instead of creating and updating a single user at a time, a list of dicts will be sent for bulk update and creation. --- bot/exts/backend/sync/_syncers.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index 156c32a15..7d1a8eacc 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -367,9 +367,10 @@ class UserSyncer(Syncer): async def _sync(self, diff: _Diff) -> None: """Synchronise the database with the user cache of `guild`.""" log.trace("Syncing created users...") - for user in diff.created: - await self.bot.api_client.post('bot/users', json=user._asdict()) + if diff.created: + created: list = [user._asdict() for user in diff.created] + await self.bot.api_client.post("bot/users", json=created) - log.trace("Syncing updated users...") - for user in diff.updated: - await self.bot.api_client.put(f'bot/users/{user.id}', json=user._asdict()) + if diff.updated: + updated = [user._asdict() for user in diff.created] + await self.bot.api_client.patch("bot/users/bulk_patch", json=updated) -- cgit v1.2.3 From eca87e32948142863c562664bde262bf9054ca94 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Tue, 22 Sep 2020 22:19:27 +0530 Subject: fix type and add variable type hinting --- bot/exts/backend/sync/_syncers.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index 7d1a8eacc..cf75b6407 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -370,7 +370,6 @@ class UserSyncer(Syncer): if diff.created: created: list = [user._asdict() for user in diff.created] await self.bot.api_client.post("bot/users", json=created) - if diff.updated: - updated = [user._asdict() for user in diff.created] + updated: list = [user._asdict() for user in diff.updated] await self.bot.api_client.patch("bot/users/bulk_patch", json=updated) -- cgit v1.2.3 From 6d9b68a0fe38fba669be242c111f5e3baac31516 Mon Sep 17 00:00:00 2001 From: Xithrius Date: Tue, 22 Sep 2020 10:40:44 -0700 Subject: Whitelisted the bot_commands channel and all staff for other channels. --- bot/exts/utils/ping.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/exts/utils/ping.py b/bot/exts/utils/ping.py index e19a2c099..a9ca3dbeb 100644 --- a/bot/exts/utils/ping.py +++ b/bot/exts/utils/ping.py @@ -6,7 +6,8 @@ from discord import Embed from discord.ext import commands from bot.bot import Bot -from bot.constants import Emojis, URLs +from bot.constants import Channels, Emojis, STAFF_ROLES, URLs +from bot.decorators import in_whitelist DESCRIPTIONS = ( "Command processing time", @@ -23,6 +24,7 @@ class Latency(commands.Cog): self.bot = bot @commands.command() + @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES) async def ping(self, ctx: commands.Context) -> None: """ Gets different measures of latency within the bot. -- cgit v1.2.3 From 675eb2fd1294766904be24758fe56cc54190a47e Mon Sep 17 00:00:00 2001 From: Xithrius Date: Tue, 22 Sep 2020 10:53:35 -0700 Subject: Updated dependencies to match with master and include aioping. --- Pipfile | 2 +- Pipfile.lock | 21 ++++++++++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/Pipfile b/Pipfile index 0c8ba1380..e6f84d911 100644 --- a/Pipfile +++ b/Pipfile @@ -9,12 +9,12 @@ aiodns = "~=2.0" aiohttp = "~=3.5" aioping = "~=0.3.1" aioredis = "~=1.3.1" +"async-rediscache[fakeredis]" = "~=0.1.2" beautifulsoup4 = "~=4.9" colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"} coloredlogs = "~=14.0" deepdiff = "~=4.0" discord.py = "~=1.4.0" -fakeredis = "~=1.4" feedparser = "~=5.2" fuzzywuzzy = "~=0.17" lxml = "~=4.4" diff --git a/Pipfile.lock b/Pipfile.lock index 1f8ff4830..acda79f11 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "0dfb214d61424e266f95666076d13e959dd9ebc049e94d5d676d00aa5438de70" + "sha256": "644012a1c3fa3e3a30f8b8f8e672c468dfaa155d9e43d26e2be8713c8dc5ebb3" }, "pipfile-spec": 6, "requires": { @@ -81,6 +81,18 @@ ], "version": "==0.7.12" }, + "async-rediscache": { + "extras": [ + "fakeredis" + ], + "hashes": [ + "sha256:407aed1aad97bf22f690eca5369806d22eefc8ca104a52c1f1bd47dd6db45fc2", + "sha256:563aaff79ec611a92a0ad78e39ff159e3a4b4cf0bea41e061de5f3701a17d50c" + ], + "index": "pypi", + "markers": "python_version ~= '3.7'", + "version": "==0.1.2" + }, "async-timeout": { "hashes": [ "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", @@ -223,7 +235,6 @@ "sha256:7ea0866ba5edb40fe2e9b1722535df0c7e6b91d518aa5f50d96c2fff3ea7f4c2", "sha256:aad8836ffe0319ffbba66dcf872ac6e7e32d1f19790e31296ba58445efb0a5c7" ], - "index": "pypi", "version": "==1.4.3" }, "feedparser": { @@ -572,11 +583,11 @@ }, "sentry-sdk": { "hashes": [ - "sha256:1a086486ff9da15791f294f6e9915eb3747d161ef64dee2d038a4d0b4a369b24", - "sha256:45486deb031cea6bbb25a540d7adb4dd48cd8a1cc31e6a5ce9fb4f792a572e9a" + "sha256:96a0e494b243a81065ec7ab73457d16719fb955ed9e469c8e4577ba737bc836e", + "sha256:a698993f3abbe06e88e8a3c8b61c8a79c12f62e503f1a23eda30c3921f0525a9" ], "index": "pypi", - "version": "==0.17.6" + "version": "==0.17.7" }, "six": { "hashes": [ -- cgit v1.2.3 From 73c21c6fda0472cd2eabaa3ffc0b58b0782ecf84 Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 22 Sep 2020 12:28:01 -0700 Subject: Sync: refactor conditional for sending message The ternary is a bit confusing. Co-authored-by: Numerlor <25886452+Numerlor@users.noreply.github.com> --- bot/cogs/sync/syncers.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index b3819a1e1..e2013dafd 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -50,7 +50,10 @@ class Syncer(abc.ABC): """ log.info(f"Starting {self.name} syncer.") - message = await ctx.send(f"📊 Synchronising {self.name}s.") if ctx else None + if ctx: + message = await ctx.send(f"📊 Synchronising {self.name}s.") + else: + message = None diff = await self._get_diff(guild) try: -- cgit v1.2.3 From 569f2aaf7b6025bddd59abb39d21939c4666ebed Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 22 Sep 2020 14:10:34 -0700 Subject: Silence: use f-string for message Co-authored-by: Numerlor <25886452+Numerlor@users.noreply.github.com> --- bot/cogs/moderation/silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index c339fd4d0..8e15b2284 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -19,7 +19,7 @@ log = logging.getLogger(__name__) MSG_SILENCE_FAIL = f"{Emojis.cross_mark} current channel is already silenced." MSG_SILENCE_PERMANENT = f"{Emojis.check_mark} silenced current channel indefinitely." -MSG_SILENCE_SUCCESS = Emojis.check_mark + " silenced current channel for {duration} minute(s)." +MSG_SILENCE_SUCCESS = f"{Emojis.check_mark} silenced current channel for {{duration}} minute(s)." MSG_UNSILENCE_FAIL = f"{Emojis.cross_mark} current channel was not silenced." MSG_UNSILENCE_MANUAL = ( -- cgit v1.2.3 From 24e58e302f1e3eaa7518183b9747c78e4a7f8f3d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 22 Sep 2020 15:07:00 -0700 Subject: Fix type annotation for expanded infractions The `_utils.Infraction` alias does not cover nested data structures. Therefore, it's inappropriate for expanded infraction API responses. --- bot/exts/moderation/infraction/management.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 622262c9b..0f3ea4bb1 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -11,7 +11,6 @@ from discord.utils import escape_markdown from bot import constants from bot.bot import Bot from bot.converters import Expiry, Snowflake, UserMention, allowed_strings, proxy_user -from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction.infractions import Infractions from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator @@ -217,7 +216,7 @@ class ModManagement(commands.Cog): self, ctx: Context, embed: discord.Embed, - infractions: t.Iterable[_utils.Infraction] + infractions: t.Iterable[t.Dict[str, t.Any]] ) -> None: """Send a paginated embed of infractions for the specified user.""" if not infractions: -- cgit v1.2.3 From 3c8ecbdad6a6e6888b3f110a8eb6b875150740b8 Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 19 Sep 2020 11:53:20 -0700 Subject: Filtering: add missing space to log msg --- bot/exts/filters/filtering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 80ec67641..92cdfb8f5 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -339,7 +339,7 @@ class Filtering(Cog): # Allow specific filters to override ping_everyone ping_everyone = Filter.ping_everyone and _filter.get("ping_everyone", True) - eval_msg = "using !eval" if is_eval else "" + eval_msg = "using !eval " if is_eval else "" message = ( f"The {filter_name} {_filter['type']} was triggered by {format_user(msg.author)} " f"{channel_str} {eval_msg}with [the following message]({msg.jump_url}):\n\n" -- cgit v1.2.3 From 5d3505fe3415882879a74ee8138b418bbb96df9a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 22 Sep 2020 16:47:50 -0700 Subject: Fix future date check in snowflake converter --- bot/converters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/converters.py b/bot/converters.py index 4cfd663ba..2e118d476 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -209,7 +209,7 @@ class Snowflake(IDConverter): if time < DISCORD_EPOCH_DT: raise BadArgument(f"{error}: timestamp is before the Discord epoch.") - elif (datetime.utcnow() - time).days >= 1: + elif (datetime.utcnow() - time).days < -1: raise BadArgument(f"{error}: timestamp is too far into the future.") return snowflake -- cgit v1.2.3 From 38a6ad5d026dd002bc5744e1d7380cab8328e4dc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 22 Sep 2020 16:49:41 -0700 Subject: Fix AttributeError for infraction user searches via the group --- bot/exts/moderation/infraction/management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 0f3ea4bb1..d448b22b2 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -179,7 +179,7 @@ class ModManagement(commands.Cog): async def infraction_search_group(self, ctx: Context, query: t.Union[UserMention, Snowflake, str]) -> None: """Searches for infractions in the database.""" if isinstance(query, int): - await ctx.invoke(self.search_user, query) + await ctx.invoke(self.search_user, discord.Object(query)) else: await ctx.invoke(self.search_reason, query) -- cgit v1.2.3 From 11397ec9b96cb63743347782a0690997059024c0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 22 Sep 2020 17:07:56 -0700 Subject: Avoid using discord.Object's repr as the username for infraction search --- bot/exts/moderation/infraction/management.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index d448b22b2..de4fb4175 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -190,6 +190,13 @@ class ModManagement(commands.Cog): 'bot/infractions/expanded', params={'user__id': str(user.id)} ) + + user = self.bot.get_user(user.id) + if not user and infraction_list: + # Use the user data retrieved from the DB for the username. + user = infraction_list[0] + user = escape_markdown(user["name"]) + f"#{user['discriminator']:04}" + embed = discord.Embed( title=f"Infractions for {user} ({len(infraction_list)} total)", colour=discord.Colour.orange() -- cgit v1.2.3 From 1ae971423ae5fcf3f8a68bcacbf1591c2ccf0aac Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 22 Sep 2020 20:44:38 -0700 Subject: Clean: fix mention in mod log message Fixes BOT-99 --- bot/exts/utils/clean.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py index 5a5ee9a81..bf25cb4c2 100644 --- a/bot/exts/utils/clean.py +++ b/bot/exts/utils/clean.py @@ -179,7 +179,7 @@ class Clean(Cog): message = ( f"**{len(message_ids)}** messages deleted in {target_channels} by " - f"{ctx.author.name.mention}\n\n" + f"{ctx.author.mention}\n\n" f"A log of the deleted messages can be found [here]({log_url})." ) -- cgit v1.2.3 From 4e49b9ffd7a359b8b6139e5cc6fc30c9e631b77d Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Wed, 23 Sep 2020 15:12:33 +0100 Subject: Update format_user to remove username and add ID --- bot/utils/messages.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 74956ed24..9cc0d8a34 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -9,7 +9,6 @@ from typing import List, Optional, Sequence, Union import discord from discord.errors import HTTPException from discord.ext.commands import Context -from discord.utils import escape_markdown from bot.constants import Emojis, NEGATIVE_REPLIES @@ -142,6 +141,5 @@ async def send_denial(ctx: Context, reason: str) -> None: def format_user(user: discord.abc.User) -> str: - """Return a string for `user` which has their mention and name#discriminator.""" - name = escape_markdown(str(user)) - return f"{user.mention} ({name})" + """Return a string for `user` which has their mention and ID.""" + return f"{user.mention} (`{user.id}`)" -- cgit v1.2.3 From bcca56c726d30f2c9e0cd762e9e65aebda2521d0 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 23 Sep 2020 17:09:58 +0200 Subject: Verification: reduce request dispatch log level Avoid information duplication in production logs. --- bot/exts/moderation/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index 210c7a1af..6bbe81701 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -286,7 +286,7 @@ class Verification(Cog): Returns the amount of successful requests. Failed requests are logged at info level. """ - log.info(f"Sending {len(members)} requests") + log.trace(f"Sending {len(members)} requests") n_success, bad_statuses = 0, set() for progress, member in enumerate(members, start=1): -- cgit v1.2.3 From 5038aea67d41f579914dec2cf93042468dc2d3cf Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 23 Sep 2020 17:10:09 +0200 Subject: Incidents: bump archive log to INFO level --- bot/exts/moderation/incidents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index e49913552..31be48a43 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -237,7 +237,7 @@ class Incidents(Cog): not all information was relayed, return False. This signals that the original message is not safe to be deleted, as we will lose some information. """ - log.debug(f"Archiving incident: {incident.id} (outcome: {outcome}, actioned by: {actioned_by})") + log.info(f"Archiving incident: {incident.id} (outcome: {outcome}, actioned by: {actioned_by})") embed, attachment_file = await make_embed(incident, outcome, actioned_by) try: -- cgit v1.2.3 From 77205149613e25623ee646de977e5d5d0cd16e11 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 23 Sep 2020 09:35:18 -0700 Subject: Fix use of expanded infraction response for username Fixes BOT-9A --- bot/exts/moderation/infraction/management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index de4fb4175..856a4e1a2 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -194,7 +194,7 @@ class ModManagement(commands.Cog): user = self.bot.get_user(user.id) if not user and infraction_list: # Use the user data retrieved from the DB for the username. - user = infraction_list[0] + user = infraction_list[0]["user"] user = escape_markdown(user["name"]) + f"#{user['discriminator']:04}" embed = discord.Embed( -- cgit v1.2.3 From 9a90d6b16e0ec61c023a916ec58d05b6142a6e2d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 23 Sep 2020 13:25:23 -0700 Subject: Sync: remove _asdict comment The comment doesn't contribute anything. --- bot/exts/backend/sync/_syncers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index e2013dafd..a07a93eab 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -65,7 +65,7 @@ class Syncer(abc.ABC): results = f"status {e.status}\n```{e.response_json or 'See log output for details'}```" content = f":x: Synchronisation of {self.name}s failed: {results}" else: - diff_dict = diff._asdict() # Ugly method for transforming the NamedTuple into a dict + diff_dict = diff._asdict() results = (f"{name} `{len(val)}`" for name, val in diff_dict.items() if val is not None) results = ", ".join(results) -- cgit v1.2.3 From 1b38ad4a16d17bacfe20513c9f33a58aa6ee1b56 Mon Sep 17 00:00:00 2001 From: Bast Date: Thu, 24 Sep 2020 10:03:24 -0700 Subject: Implement review-suggested changes userid -> user ID maybevalid -> maybe_valid remove collections import and added a new function that handles the "format user ID log message" and should_ping_everyone feature --- bot/exts/filters/token_remover.py | 71 ++++++++++++----------- tests/bot/exts/filters/test_token_remover.py | 87 +++++++++++++++++----------- 2 files changed, 91 insertions(+), 67 deletions(-) diff --git a/bot/exts/filters/token_remover.py b/bot/exts/filters/token_remover.py index a31912d5b..54f0bc034 100644 --- a/bot/exts/filters/token_remover.py +++ b/bot/exts/filters/token_remover.py @@ -1,6 +1,5 @@ import base64 import binascii -import collections import logging import re import typing as t @@ -98,14 +97,8 @@ class TokenRemover(Cog): await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention)) - user_name = None - user_id = self.extract_user_id(found_token.user_id) - user = msg.guild.get_member(user_id) - - if user: - user_name = str(user) - - log_message = self.format_log_message(msg, found_token, user_id, user_name) + log_message = self.format_log_message(msg, found_token) + userid_message, mention_everyone = self.format_userid_log_message(msg, found_token) log.debug(log_message) # Send pretty mod log embed to mod-alerts @@ -113,26 +106,35 @@ class TokenRemover(Cog): icon_url=Icons.token_removed, colour=Colour(Colours.soft_red), title="Token removed!", - text=log_message, + text=log_message + "\n" + userid_message, thumbnail=msg.author.avatar_url_as(static_format="png"), channel_id=Channels.mod_alerts, - ping_everyone=user_name is not None, + ping_everyone=mention_everyone, ) self.bot.stats.incr("tokens.removed_tokens") - @staticmethod - def format_log_message( - msg: Message, - token: Token, - user_id: int, - user_name: t.Optional[str] = None, - ) -> str: + @classmethod + def format_userid_log_message(cls, msg: Message, token: Token) -> t.Tuple[str, bool]: """ - Return the log message to send for `token` being censored in `msg`. + Format the potion of the log message that includes details about the detected user ID. - Additonally, mention if the token was decodable into a user id, and if that resolves to a user on the server. + Includes the user ID and, if present on the server, their name and a toggle to + mention everyone. + + Returns a tuple of (log_message, mention_everyone) """ + user_id = cls.extract_user_id(token.user_id) + user = msg.guild.get_member(user_id) + + if user: + return USER_TOKEN_MESSAGE.format(user_id=user_id, user_name=str(user)), True + else: + return DECODED_LOG_MESSAGE.format(user_id=user_id), False + + @staticmethod + def format_log_message(msg: Message, token: Token) -> str: + """Return the generic portion of the log message to send for `token` being censored in `msg`.""" message = LOG_MESSAGE.format( author=msg.author, author_id=msg.author.id, @@ -141,11 +143,8 @@ class TokenRemover(Cog): timestamp=token.timestamp, hmac='x' * len(token.hmac), ) - if user_name: - more = USER_TOKEN_MESSAGE.format(user_id=user_id, user_name=user_name) - else: - more = DECODED_LOG_MESSAGE.format(user_id=user_id) - return message + "\n" + more + + return message @classmethod def find_token_in_message(cls, msg: Message) -> t.Optional[Token]: @@ -154,9 +153,11 @@ class TokenRemover(Cog): # token check (e.g. `message.channel.send` also matches our token pattern) for match in TOKEN_RE.finditer(msg.content): token = Token(*match.groups()) - if cls.is_valid_user_id(token.user_id) \ - and cls.is_valid_timestamp(token.timestamp) \ - and cls.is_maybevalid_hmac(token.hmac): + if ( + cls.is_valid_user_id(token.user_id) + and cls.is_valid_timestamp(token.timestamp) + and cls.is_maybe_valid_hmac(token.hmac) + ): # Short-circuit on first match return token @@ -165,7 +166,7 @@ class TokenRemover(Cog): @staticmethod def extract_user_id(b64_content: str) -> t.Optional[int]: - """Return a userid integer from part of a potential token, or None if it couldn't be decoded.""" + """Return a user ID integer from part of a potential token, or None if it couldn't be decoded.""" b64_content = utils.pad_base64(b64_content) try: @@ -218,17 +219,19 @@ class TokenRemover(Cog): return False @staticmethod - def is_maybevalid_hmac(b64_content: str) -> bool: + def is_maybe_valid_hmac(b64_content: str) -> bool: """ - Determine if a given hmac portion of a token is potentially valid. + Determine if a given HMAC portion of a token is potentially valid. If the HMAC has 3 or less characters, it's probably a dummy value like "xxxxxxxxxx", and thus the token can probably be skipped. """ - unique = len(collections.Counter(b64_content.lower()).keys()) + unique = len(set(b64_content.lower())) if unique <= 3: - log.debug(f"Considering the hmac {b64_content} a dummy because it has {unique}" - " case-insensitively unique characters") + log.debug( + f"Considering the HMAC {b64_content} a dummy because it has {unique}" + " case-insensitively unique characters" + ) return False else: return True diff --git a/tests/bot/exts/filters/test_token_remover.py b/tests/bot/exts/filters/test_token_remover.py index 8742b73c5..92dce201b 100644 --- a/tests/bot/exts/filters/test_token_remover.py +++ b/tests/bot/exts/filters/test_token_remover.py @@ -87,7 +87,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(result) def test_is_valid_hmac_valid(self): - """Should consider hmac valid if it is a valid hmac with a variety of characters.""" + """Should consider an HMAC valid if it has at least 3 unique characters.""" valid_hmacs = ( "VXmErH7j511turNpfURmb0rVNm8", "Ysnu2wacjaKs7qnoo46S8Dm2us8", @@ -97,11 +97,11 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): for hmac in valid_hmacs: with self.subTest(msg=hmac): - result = TokenRemover.is_maybevalid_hmac(hmac) + result = TokenRemover.is_maybe_valid_hmac(hmac) self.assertTrue(result) def test_is_invalid_hmac_invalid(self): - """Should consider hmac invalid if it possesses too little variety.""" + """Should consider an HMAC invalid if has fewer than 3 unique characters.""" invalid_hmacs = ( ("xxxxxxxxxxxxxxxxxx", "Single character"), ("XxXxXxXxXxXxXxXxXx", "Single character alternating case"), @@ -111,7 +111,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): for hmac, msg in invalid_hmacs: with self.subTest(msg=msg): - result = TokenRemover.is_maybevalid_hmac(hmac) + result = TokenRemover.is_maybe_valid_hmac(hmac) self.assertFalse(result) def test_mod_log_property(self): @@ -171,11 +171,11 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(return_value) token_re.finditer.assert_called_once_with(self.msg.content) - @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp", "is_maybevalid_hmac") + @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp", "is_maybe_valid_hmac") @autospec("bot.exts.filters.token_remover", "Token") @autospec("bot.exts.filters.token_remover", "TOKEN_RE") - def test_find_token_valid_match(self, token_re, token_cls, is_valid_id, is_valid_timestamp, is_maybevalid_hmac): - """The first match with a valid user ID. timestamp and hmac should be returned as a `Token`.""" + def test_find_token_valid_match(self, token_re, token_cls, is_valid_id, is_valid_timestamp, is_maybe_valid_hmac): + """The first match with a valid user ID, timestamp, and HMAC should be returned as a `Token`.""" matches = [ mock.create_autospec(Match, spec_set=True, instance=True), mock.create_autospec(Match, spec_set=True, instance=True), @@ -189,23 +189,30 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): token_cls.side_effect = tokens is_valid_id.side_effect = (False, True) # The 1st match will be invalid, 2nd one valid. is_valid_timestamp.return_value = True - is_maybevalid_hmac.return_value = True + is_maybe_valid_hmac.return_value = True return_value = TokenRemover.find_token_in_message(self.msg) self.assertEqual(tokens[1], return_value) token_re.finditer.assert_called_once_with(self.msg.content) - @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp", "is_maybevalid_hmac") + @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp", "is_maybe_valid_hmac") @autospec("bot.exts.filters.token_remover", "Token") @autospec("bot.exts.filters.token_remover", "TOKEN_RE") - def test_find_token_invalid_matches(self, token_re, token_cls, is_valid_id, is_valid_timestamp, is_maybevalid_hmac): + def test_find_token_invalid_matches( + self, + token_re, + token_cls, + is_valid_id, + is_valid_timestamp, + is_maybe_valid_hmac, + ): """None should be returned if no matches have valid user IDs or timestamps.""" token_re.finditer.return_value = [mock.create_autospec(Match, spec_set=True, instance=True)] token_cls.return_value = mock.create_autospec(Token, spec_set=True, instance=True) is_valid_id.return_value = False is_valid_timestamp.return_value = False - is_maybevalid_hmac.return_value = False + is_maybe_valid_hmac.return_value = False return_value = TokenRemover.find_token_in_message(self.msg) @@ -261,18 +268,17 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): results = [match[0] for match in results] self.assertCountEqual((token_1, token_2), results) - @autospec("bot.exts.filters.token_remover", "LOG_MESSAGE", "DECODED_LOG_MESSAGE") - def test_format_log_message(self, log_message, decoded_log_message): + @autospec("bot.exts.filters.token_remover", "LOG_MESSAGE") + def test_format_log_message(self, log_message): """Should correctly format the log message with info from the message and token.""" token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") log_message.format.return_value = "Howdy" - decoded_log_message.format.return_value = " Partner" - return_value = TokenRemover.format_log_message(self.msg, token, 472265943062413332, None) + return_value = TokenRemover.format_log_message(self.msg, token) self.assertEqual( return_value, - log_message.format.return_value + "\n" + decoded_log_message.format.return_value, + log_message.format.return_value, ) log_message.format.assert_called_once_with( author=self.msg.author, @@ -283,26 +289,38 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): hmac="x" * len(token.hmac), ) - @autospec("bot.exts.filters.token_remover", "LOG_MESSAGE", "USER_TOKEN_MESSAGE") - def test_format_log_message_user_token(self, log_message, user_token_message): + @autospec("bot.exts.filters.token_remover", "DECODED_LOG_MESSAGE") + def test_format_userid_log_message_bot(self, decoded_log_message): + """ + Should correctly format the user ID portion of the log message when the user ID is + not found in the server. + """ + token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") + decoded_log_message.format.return_value = " Partner" + msg = MockMessage(id=555, content="hello world") + msg.guild.get_member = MagicMock(return_value=None) + + return_value = TokenRemover.format_userid_log_message(msg, token) + + self.assertEqual( + return_value, + (decoded_log_message.format.return_value, False), + ) + decoded_log_message.format.assert_called_once_with( + user_id=472265943062413332, + ) + + @autospec("bot.exts.filters.token_remover", "USER_TOKEN_MESSAGE") + def test_format_log_message_user_token_user(self, user_token_message): """Should correctly format the log message with info from the message and token.""" token = Token("NDY3MjIzMjMwNjUwNzc3NjQx", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") - log_message.format.return_value = "Howdy" user_token_message.format.return_value = "Partner" - return_value = TokenRemover.format_log_message(self.msg, token, 467223230650777641, "Bob") + return_value = TokenRemover.format_userid_log_message(self.msg, token) self.assertEqual( return_value, - log_message.format.return_value + "\n" + user_token_message.format.return_value, - ) - log_message.format.assert_called_once_with( - author=self.msg.author, - author_id=self.msg.author.id, - channel=self.msg.channel.mention, - user_id=token.user_id, - timestamp=token.timestamp, - hmac="x" * len(token.hmac), + (user_token_message.format.return_value, True), ) user_token_message.format.assert_called_once_with( user_id=467223230650777641, @@ -311,17 +329,19 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): @mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock) @autospec("bot.exts.filters.token_remover", "log") - @autospec(TokenRemover, "format_log_message") - async def test_take_action(self, format_log_message, logger, mod_log_property): + @autospec(TokenRemover, "format_log_message", "format_userid_log_message") + async def test_take_action(self, format_log_message, format_userid_log_message, logger, mod_log_property): """Should delete the message and send a mod log.""" cog = TokenRemover(self.bot) mod_log = mock.create_autospec(ModLog, spec_set=True, instance=True) token = mock.create_autospec(Token, spec_set=True, instance=True) token.user_id = "no-id" log_msg = "testing123" + userid_log_message = "userid-log-message" mod_log_property.return_value = mod_log format_log_message.return_value = log_msg + format_userid_log_message.return_value = (userid_log_message, True) await cog.take_action(self.msg, token) @@ -330,7 +350,8 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): token_remover.DELETION_MESSAGE_TEMPLATE.format(mention=self.msg.author.mention) ) - format_log_message.assert_called_once_with(self.msg, token, None, "Bob") + format_log_message.assert_called_once_with(self.msg, token) + format_userid_log_message.assert_called_once_with(self.msg, token) logger.debug.assert_called_with(log_msg) self.bot.stats.incr.assert_called_once_with("tokens.removed_tokens") @@ -339,7 +360,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): icon_url=constants.Icons.token_removed, colour=Colour(constants.Colours.soft_red), title="Token removed!", - text=log_msg, + text=log_msg + "\n" + userid_log_message, thumbnail=self.msg.author.avatar_url_as.return_value, channel_id=constants.Channels.mod_alerts, ping_everyone=True, -- cgit v1.2.3 From b62db241766e20d54093273a7457cc52d34e3f75 Mon Sep 17 00:00:00 2001 From: Bast Date: Thu, 24 Sep 2020 10:26:45 -0700 Subject: Add BOT vs USER token detection, properly handling bot tokens for bots in the current server Also adjust the naming and purposes of the format messages to KNOWN and UNKNOWN token messages. --- bot/exts/filters/token_remover.py | 14 ++++++--- tests/bot/exts/filters/test_token_remover.py | 46 +++++++++++++++++++++++----- 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/bot/exts/filters/token_remover.py b/bot/exts/filters/token_remover.py index 54f0bc034..87d4aa135 100644 --- a/bot/exts/filters/token_remover.py +++ b/bot/exts/filters/token_remover.py @@ -18,10 +18,10 @@ LOG_MESSAGE = ( "Censored a seemingly valid token sent by {author} (`{author_id}`) in {channel}, " "token was `{user_id}.{timestamp}.{hmac}`" ) -DECODED_LOG_MESSAGE = "The token user_id decodes into {user_id}." -USER_TOKEN_MESSAGE = ( +UNKNOWN_USER_LOG_MESSAGE = "The token user_id decodes into {user_id}." +KNOWN_USER_LOG_MESSAGE = ( "The token user_id decodes into {user_id}, " - "which matches `{user_name}` and means this is a valid USER token." + "which matches `{user_name}` and means this is a valid {kind} token." ) DELETION_MESSAGE_TEMPLATE = ( "Hey {mention}! I noticed you posted a seemingly valid Discord API " @@ -128,9 +128,13 @@ class TokenRemover(Cog): user = msg.guild.get_member(user_id) if user: - return USER_TOKEN_MESSAGE.format(user_id=user_id, user_name=str(user)), True + return KNOWN_USER_LOG_MESSAGE.format( + user_id=user_id, + user_name=str(user), + kind="BOT" if user.bot else "USER", + ), not user.bot else: - return DECODED_LOG_MESSAGE.format(user_id=user_id), False + return UNKNOWN_USER_LOG_MESSAGE.format(user_id=user_id), False @staticmethod def format_log_message(msg: Message, token: Token) -> str: diff --git a/tests/bot/exts/filters/test_token_remover.py b/tests/bot/exts/filters/test_token_remover.py index 92dce201b..90d40d1df 100644 --- a/tests/bot/exts/filters/test_token_remover.py +++ b/tests/bot/exts/filters/test_token_remover.py @@ -22,7 +22,12 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.msg = MockMessage(id=555, content="hello world") self.msg.channel.mention = "#lemonade-stand" - self.msg.guild.get_member = MagicMock(return_value="Bob") + self.msg.guild.get_member = MagicMock( + return_value=MagicMock( + bot=False, + __str__=MagicMock(return_value="Woody"), + ), + ) self.msg.author.__str__ = MagicMock(return_value=self.msg.author.name) self.msg.author.avatar_url_as.return_value = "picture-lemon.png" @@ -289,14 +294,14 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): hmac="x" * len(token.hmac), ) - @autospec("bot.exts.filters.token_remover", "DECODED_LOG_MESSAGE") - def test_format_userid_log_message_bot(self, decoded_log_message): + @autospec("bot.exts.filters.token_remover", "UNKNOWN_USER_LOG_MESSAGE") + def test_format_userid_log_message_unknown(self, unknown_user_log_message): """ Should correctly format the user ID portion of the log message when the user ID is not found in the server. """ token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") - decoded_log_message.format.return_value = " Partner" + unknown_user_log_message.format.return_value = " Partner" msg = MockMessage(id=555, content="hello world") msg.guild.get_member = MagicMock(return_value=None) @@ -304,13 +309,37 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.assertEqual( return_value, - (decoded_log_message.format.return_value, False), + (unknown_user_log_message.format.return_value, False), + ) + unknown_user_log_message.format.assert_called_once_with( + user_id=472265943062413332, ) - decoded_log_message.format.assert_called_once_with( + + @autospec("bot.exts.filters.token_remover", "KNOWN_USER_LOG_MESSAGE") + def test_format_userid_log_message_bot(self, known_user_log_message): + """ + Should correctly format the user ID portion of the log message when the user ID is + not found in the server. + """ + token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") + known_user_log_message.format.return_value = " Partner" + msg = MockMessage(id=555, content="hello world") + msg.guild.get_member = MagicMock(return_value=MagicMock(__str__=MagicMock(return_value="Sam"), bot=True)) + + return_value = TokenRemover.format_userid_log_message(msg, token) + + self.assertEqual( + return_value, + (known_user_log_message.format.return_value, False), + ) + + known_user_log_message.format.assert_called_once_with( user_id=472265943062413332, + user_name="Sam", + kind="BOT", ) - @autospec("bot.exts.filters.token_remover", "USER_TOKEN_MESSAGE") + @autospec("bot.exts.filters.token_remover", "KNOWN_USER_LOG_MESSAGE") def test_format_log_message_user_token_user(self, user_token_message): """Should correctly format the log message with info from the message and token.""" token = Token("NDY3MjIzMjMwNjUwNzc3NjQx", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") @@ -324,7 +353,8 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): ) user_token_message.format.assert_called_once_with( user_id=467223230650777641, - user_name="Bob", + user_name="Woody", + kind="USER", ) @mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock) -- cgit v1.2.3 From ce80892eb3928c7c312a221c9d0271698f1563f4 Mon Sep 17 00:00:00 2001 From: Bast Date: Thu, 24 Sep 2020 14:16:10 -0700 Subject: Change the mod alert message component for the user token detection Clean up mock usage, docstrings, unnecessarily split-lined function calls --- bot/exts/filters/token_remover.py | 18 +++++----- tests/bot/exts/filters/test_token_remover.py | 51 ++++++++-------------------- 2 files changed, 23 insertions(+), 46 deletions(-) diff --git a/bot/exts/filters/token_remover.py b/bot/exts/filters/token_remover.py index 87d4aa135..87072e161 100644 --- a/bot/exts/filters/token_remover.py +++ b/bot/exts/filters/token_remover.py @@ -18,10 +18,10 @@ LOG_MESSAGE = ( "Censored a seemingly valid token sent by {author} (`{author_id}`) in {channel}, " "token was `{user_id}.{timestamp}.{hmac}`" ) -UNKNOWN_USER_LOG_MESSAGE = "The token user_id decodes into {user_id}." +UNKNOWN_USER_LOG_MESSAGE = "Decoded user ID: `{user_id}` (Not present in server)." KNOWN_USER_LOG_MESSAGE = ( - "The token user_id decodes into {user_id}, " - "which matches `{user_name}` and means this is a valid {kind} token." + "Decoded user ID: `{user_id}` **(Present in server)**.\n" + "This matches `{user_name}` and means this is likely a valid **{kind}** token." ) DELETION_MESSAGE_TEMPLATE = ( "Hey {mention}! I noticed you posted a seemingly valid Discord API " @@ -117,10 +117,12 @@ class TokenRemover(Cog): @classmethod def format_userid_log_message(cls, msg: Message, token: Token) -> t.Tuple[str, bool]: """ - Format the potion of the log message that includes details about the detected user ID. + Format the portion of the log message that includes details about the detected user ID. - Includes the user ID and, if present on the server, their name and a toggle to - mention everyone. + If the user is resolved to a member, the format includes the user ID, name, and the + kind of user detected. + + If we resolve to a member and it is not a bot, we also return True to ping everyone. Returns a tuple of (log_message, mention_everyone) """ @@ -139,7 +141,7 @@ class TokenRemover(Cog): @staticmethod def format_log_message(msg: Message, token: Token) -> str: """Return the generic portion of the log message to send for `token` being censored in `msg`.""" - message = LOG_MESSAGE.format( + return LOG_MESSAGE.format( author=msg.author, author_id=msg.author.id, channel=msg.channel.mention, @@ -148,8 +150,6 @@ class TokenRemover(Cog): hmac='x' * len(token.hmac), ) - return message - @classmethod def find_token_in_message(cls, msg: Message) -> t.Optional[Token]: """Return a seemingly valid token found in `msg` or `None` if no token is found.""" diff --git a/tests/bot/exts/filters/test_token_remover.py b/tests/bot/exts/filters/test_token_remover.py index 90d40d1df..5f28ab571 100644 --- a/tests/bot/exts/filters/test_token_remover.py +++ b/tests/bot/exts/filters/test_token_remover.py @@ -22,12 +22,8 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.msg = MockMessage(id=555, content="hello world") self.msg.channel.mention = "#lemonade-stand" - self.msg.guild.get_member = MagicMock( - return_value=MagicMock( - bot=False, - __str__=MagicMock(return_value="Woody"), - ), - ) + self.msg.guild.get_member.return_value.bot = False + self.msg.guild.get_member.return_value.__str__.return_value = "Woody" self.msg.author.__str__ = MagicMock(return_value=self.msg.author.name) self.msg.author.avatar_url_as.return_value = "picture-lemon.png" @@ -212,7 +208,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): is_valid_timestamp, is_maybe_valid_hmac, ): - """None should be returned if no matches have valid user IDs or timestamps.""" + """None should be returned if no matches have valid user IDs, HMACs, and timestamps.""" token_re.finditer.return_value = [mock.create_autospec(Match, spec_set=True, instance=True)] token_cls.return_value = mock.create_autospec(Token, spec_set=True, instance=True) is_valid_id.return_value = False @@ -281,10 +277,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): return_value = TokenRemover.format_log_message(self.msg, token) - self.assertEqual( - return_value, - log_message.format.return_value, - ) + self.assertEqual(return_value, log_message.format.return_value) log_message.format.assert_called_once_with( author=self.msg.author, author_id=self.msg.author.id, @@ -296,42 +289,29 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): @autospec("bot.exts.filters.token_remover", "UNKNOWN_USER_LOG_MESSAGE") def test_format_userid_log_message_unknown(self, unknown_user_log_message): - """ - Should correctly format the user ID portion of the log message when the user ID is - not found in the server. - """ + """Should correctly format the user ID portion when the actual user it belongs to is unknown.""" token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") unknown_user_log_message.format.return_value = " Partner" msg = MockMessage(id=555, content="hello world") - msg.guild.get_member = MagicMock(return_value=None) + msg.guild.get_member.return_value = None return_value = TokenRemover.format_userid_log_message(msg, token) - self.assertEqual( - return_value, - (unknown_user_log_message.format.return_value, False), - ) - unknown_user_log_message.format.assert_called_once_with( - user_id=472265943062413332, - ) + self.assertEqual(return_value, (unknown_user_log_message.format.return_value, False)) + unknown_user_log_message.format.assert_called_once_with(user_id=472265943062413332) @autospec("bot.exts.filters.token_remover", "KNOWN_USER_LOG_MESSAGE") def test_format_userid_log_message_bot(self, known_user_log_message): - """ - Should correctly format the user ID portion of the log message when the user ID is - not found in the server. - """ + """Should correctly format the user ID portion when the ID belongs to a known bot.""" token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") known_user_log_message.format.return_value = " Partner" msg = MockMessage(id=555, content="hello world") - msg.guild.get_member = MagicMock(return_value=MagicMock(__str__=MagicMock(return_value="Sam"), bot=True)) + msg.guild.get_member.return_value.__str__.return_value = "Sam" + msg.guild.get_member.return_value.bot = True return_value = TokenRemover.format_userid_log_message(msg, token) - self.assertEqual( - return_value, - (known_user_log_message.format.return_value, False), - ) + self.assertEqual(return_value, (known_user_log_message.format.return_value, False)) known_user_log_message.format.assert_called_once_with( user_id=472265943062413332, @@ -341,16 +321,13 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): @autospec("bot.exts.filters.token_remover", "KNOWN_USER_LOG_MESSAGE") def test_format_log_message_user_token_user(self, user_token_message): - """Should correctly format the log message with info from the message and token.""" + """Should correctly format the user ID portion when the ID belongs to a known user.""" token = Token("NDY3MjIzMjMwNjUwNzc3NjQx", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") user_token_message.format.return_value = "Partner" return_value = TokenRemover.format_userid_log_message(self.msg, token) - self.assertEqual( - return_value, - (user_token_message.format.return_value, True), - ) + self.assertEqual(return_value, (user_token_message.format.return_value, True)) user_token_message.format.assert_called_once_with( user_id=467223230650777641, user_name="Woody", -- cgit v1.2.3 From 3f87c52f484afc1316e87f67f4055d5d615b054a Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Fri, 25 Sep 2020 15:29:14 +0530 Subject: Update users on bot start via HTTP PATCH method and send only user ID and the modified user data. --- bot/exts/backend/sync/_syncers.py | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index cf75b6407..512efaa3d 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -316,9 +316,18 @@ class UserSyncer(Syncer): for db_user in db_users.values(): guild_user = guild_users.get(db_user.id) + if guild_user is not None: if db_user != guild_user: - users_to_update.add(guild_user) + fields_to_none: dict = {} + + for field in _User._fields: + # Set un-changed values to None except ID to speed up API PATCH method. + if getattr(db_user, field) == getattr(guild_user, field) and field != "id": + fields_to_none[field] = None + + new_api_user = guild_user._replace(**fields_to_none) + users_to_update.add(new_api_user) elif db_user.in_guild: # The user is known in the DB but not the guild, and the @@ -326,7 +335,13 @@ class UserSyncer(Syncer): # This means that the user has left since the last sync. # Update the `in_guild` attribute of the user on the site # to signify that the user left. - new_api_user = db_user._replace(in_guild=False) + + # Set un-changed fields to None except ID as it is required by the API. + fields_to_none: dict = {field: None for field in db_user._fields if field not in ["id", "in_guild"]} + new_api_user = db_user._replace( + in_guild=False, + **fields_to_none + ) users_to_update.add(new_api_user) new_user_ids = set(guild_users.keys()) - set(db_users.keys()) @@ -364,6 +379,15 @@ class UserSyncer(Syncer): return endpoint, params + @staticmethod + def patch_dict(user: _User) -> dict: + """Convert namedtuple to dict by omitting None values.""" + user_dict: dict = {} + for field in user._fields: + if (value := getattr(user, field)) is not None: + user_dict[field] = value + return user_dict + async def _sync(self, diff: _Diff) -> None: """Synchronise the database with the user cache of `guild`.""" log.trace("Syncing created users...") @@ -371,5 +395,5 @@ class UserSyncer(Syncer): created: list = [user._asdict() for user in diff.created] await self.bot.api_client.post("bot/users", json=created) if diff.updated: - updated: list = [user._asdict() for user in diff.updated] + updated: list = [self.patch_dict(user) for user in diff.updated] await self.bot.api_client.patch("bot/users/bulk_patch", json=updated) -- cgit v1.2.3 From 840a3c504138ef601583cdf489908b2b6b30691f Mon Sep 17 00:00:00 2001 From: Bast Date: Fri, 25 Sep 2020 07:50:37 -0700 Subject: Remove redundant is_valid_userid function extract_user_id(id) is not None does the same job and is not worth the extra function --- bot/exts/filters/token_remover.py | 15 +--------- tests/bot/exts/filters/test_token_remover.py | 45 ++++++++++++++++------------ 2 files changed, 27 insertions(+), 33 deletions(-) diff --git a/bot/exts/filters/token_remover.py b/bot/exts/filters/token_remover.py index 87072e161..3eb68c13c 100644 --- a/bot/exts/filters/token_remover.py +++ b/bot/exts/filters/token_remover.py @@ -158,7 +158,7 @@ class TokenRemover(Cog): for match in TOKEN_RE.finditer(msg.content): token = Token(*match.groups()) if ( - cls.is_valid_user_id(token.user_id) + (cls.extract_user_id(token.user_id) is not None) and cls.is_valid_timestamp(token.timestamp) and cls.is_maybe_valid_hmac(token.hmac) ): @@ -184,19 +184,6 @@ class TokenRemover(Cog): except (binascii.Error, ValueError): return None - @classmethod - def is_valid_user_id(cls, b64_content: str) -> bool: - """ - Check potential token to see if it contains a valid Discord user ID. - - See: https://discordapp.com/developers/docs/reference#snowflakes - """ - decoded_id = cls.extract_user_id(b64_content) - if not decoded_id: - return False - - return True - @staticmethod def is_valid_timestamp(b64_content: str) -> bool: """ diff --git a/tests/bot/exts/filters/test_token_remover.py b/tests/bot/exts/filters/test_token_remover.py index 5f28ab571..f14780b02 100644 --- a/tests/bot/exts/filters/test_token_remover.py +++ b/tests/bot/exts/filters/test_token_remover.py @@ -27,20 +27,20 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.msg.author.__str__ = MagicMock(return_value=self.msg.author.name) self.msg.author.avatar_url_as.return_value = "picture-lemon.png" - def test_is_valid_user_id_valid(self): - """Should consider user IDs valid if they decode entirely to ASCII digits.""" - ids = ( - "NDcyMjY1OTQzMDYyNDEzMzMy", - "NDc1MDczNjI5Mzk5NTQ3OTA0", - "NDY3MjIzMjMwNjUwNzc3NjQx", + def test_extract_user_id_valid(self): + """Should consider user IDs valid if they decode into an integer ID.""" + id_pairs = ( + ("NDcyMjY1OTQzMDYyNDEzMzMy", 472265943062413332), + ("NDc1MDczNjI5Mzk5NTQ3OTA0", 475073629399547904), + ("NDY3MjIzMjMwNjUwNzc3NjQx", 467223230650777641), ) - for user_id in ids: - with self.subTest(user_id=user_id): - result = TokenRemover.is_valid_user_id(user_id) - self.assertTrue(result) + for token_id, user_id in id_pairs: + with self.subTest(token_id=token_id): + result = TokenRemover.extract_user_id(token_id) + self.assertEqual(result, user_id) - def test_is_valid_user_id_invalid(self): + def test_extract_user_id_invalid(self): """Should consider non-digit and non-ASCII IDs invalid.""" ids = ( ("SGVsbG8gd29ybGQ", "non-digit ASCII"), @@ -54,8 +54,8 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): for user_id, msg in ids: with self.subTest(msg=msg): - result = TokenRemover.is_valid_user_id(user_id) - self.assertFalse(result) + result = TokenRemover.extract_user_id(user_id) + self.assertIsNone(result) def test_is_valid_timestamp_valid(self): """Should consider timestamps valid if they're greater than the Discord epoch.""" @@ -172,10 +172,17 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(return_value) token_re.finditer.assert_called_once_with(self.msg.content) - @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp", "is_maybe_valid_hmac") + @autospec(TokenRemover, "extract_user_id", "is_valid_timestamp", "is_maybe_valid_hmac") @autospec("bot.exts.filters.token_remover", "Token") @autospec("bot.exts.filters.token_remover", "TOKEN_RE") - def test_find_token_valid_match(self, token_re, token_cls, is_valid_id, is_valid_timestamp, is_maybe_valid_hmac): + def test_find_token_valid_match( + self, + token_re, + token_cls, + extract_user_id, + is_valid_timestamp, + is_maybe_valid_hmac, + ): """The first match with a valid user ID, timestamp, and HMAC should be returned as a `Token`.""" matches = [ mock.create_autospec(Match, spec_set=True, instance=True), @@ -188,7 +195,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): token_re.finditer.return_value = matches token_cls.side_effect = tokens - is_valid_id.side_effect = (False, True) # The 1st match will be invalid, 2nd one valid. + extract_user_id.side_effect = (None, True) # The 1st match will be invalid, 2nd one valid. is_valid_timestamp.return_value = True is_maybe_valid_hmac.return_value = True @@ -197,21 +204,21 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(tokens[1], return_value) token_re.finditer.assert_called_once_with(self.msg.content) - @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp", "is_maybe_valid_hmac") + @autospec(TokenRemover, "extract_user_id", "is_valid_timestamp", "is_maybe_valid_hmac") @autospec("bot.exts.filters.token_remover", "Token") @autospec("bot.exts.filters.token_remover", "TOKEN_RE") def test_find_token_invalid_matches( self, token_re, token_cls, - is_valid_id, + extract_user_id, is_valid_timestamp, is_maybe_valid_hmac, ): """None should be returned if no matches have valid user IDs, HMACs, and timestamps.""" token_re.finditer.return_value = [mock.create_autospec(Match, spec_set=True, instance=True)] token_cls.return_value = mock.create_autospec(Token, spec_set=True, instance=True) - is_valid_id.return_value = False + extract_user_id.return_value = None is_valid_timestamp.return_value = False is_maybe_valid_hmac.return_value = False -- cgit v1.2.3 From a96c5434a51a074584e46058591e4e27c91538f7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 25 Sep 2020 14:05:12 -0700 Subject: Add license & copyright for autospec's _decoration_helper --- LICENSE-THIRD-PARTY | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 LICENSE-THIRD-PARTY diff --git a/LICENSE-THIRD-PARTY b/LICENSE-THIRD-PARTY new file mode 100644 index 000000000..a126700a3 --- /dev/null +++ b/LICENSE-THIRD-PARTY @@ -0,0 +1,52 @@ +--------------------------------------------------------------------------------------------------- + PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 +Applies to: + - Copyright © 2001-2020 Python Software Foundation. All rights reserved. + - tests/_autospec.py: _decoration_helper +--------------------------------------------------------------------------------------------------- + +1. This LICENSE AGREEMENT is between the Python Software Foundation +("PSF"), and the Individual or Organization ("Licensee") accessing and +otherwise using this software ("Python") in source or binary form and +its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, PSF hereby +grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, +analyze, test, perform and/or display publicly, prepare derivative works, +distribute, and otherwise use Python alone or in any derivative version, +provided, however, that PSF's License Agreement and PSF's notice of copyright, +i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, +2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020 Python Software Foundation; +All Rights Reserved" are retained in Python alone or in any derivative version +prepared by Licensee. + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python. + +4. PSF is making Python available to Licensee on an "AS IS" +basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. Nothing in this License Agreement shall be deemed to create any +relationship of agency, partnership, or joint venture between PSF and +Licensee. This License Agreement does not grant permission to use PSF +trademarks or trade name in a trademark sense to endorse or promote +products or services of Licensee, or any third party. + +8. By copying, installing or otherwise using Python, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. -- cgit v1.2.3 From f81920ad6427490a06061cfb8533828b26735dcf Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 26 Sep 2020 12:23:20 -0700 Subject: Sync: update sync() docstring --- bot/exts/backend/sync/_syncers.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index a07a93eab..3d4a09df3 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -44,9 +44,7 @@ class Syncer(abc.ABC): """ Synchronise the database with the cache of `guild`. - If the differences between the cache and the database are greater than - `bot.constants.Sync.max_diff`, then a confirmation prompt will be sent to the dev-core - channel. The confirmation can be optionally redirect to `ctx` instead. + If `ctx` is given, send a message with the results. """ log.info(f"Starting {self.name} syncer.") -- cgit v1.2.3 From 107ca75eedb2cdc140df9b9116b53998bfd61cfe Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 27 Sep 2020 14:27:36 +0200 Subject: Add the video to the welcome DM. This rewords the welcome DM, and adds the new Welcome To Python Discord video to it. --- bot/exts/moderation/verification.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index 6bbe81701..e9ab2c816 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -21,12 +21,15 @@ log = logging.getLogger(__name__) # Sent via DMs once user joins the guild ON_JOIN_MESSAGE = f""" -Hello! Welcome to Python Discord! +Welcome to Python Discord! -As a new user, you have read-only access to a few select channels to give you a taste of what our server is like. +To show you what kind of community we are, we've created this video: +https://youtu.be/ZH26PuX3re0 -In order to see the rest of the channels and to send messages, you first have to accept our rules. To do so, \ -please visit <#{constants.Channels.verification}>. Thank you! +As a new user, you have read-only access to a few select channels to give you a taste of what our server is like. \ +In order to see the rest of the channels and to send messages, you first have to accept our rules. + +Please visit <#{constants.Channels.verification}> to get started. Thank you! """ # Sent via DMs once user verifies -- cgit v1.2.3 From 2032391d50c16d15ab71fb0b29081c89bf77e751 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 27 Sep 2020 14:41:36 +0200 Subject: Relock Pipfile to update async-redis. This also bumps minor versions of several other packages. I've spun up the bot and played around with it, and run all unit tests. Everything still seems to be in order. --- Pipfile.lock | 132 +++++++++++++++++++++++++++++++---------------------------- 1 file changed, 70 insertions(+), 62 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index f75852081..4c63277de 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -86,12 +86,12 @@ "fakeredis" ], "hashes": [ - "sha256:407aed1aad97bf22f690eca5369806d22eefc8ca104a52c1f1bd47dd6db45fc2", - "sha256:563aaff79ec611a92a0ad78e39ff159e3a4b4cf0bea41e061de5f3701a17d50c" + "sha256:6be8a657d724ccbcfb1946d29a80c3478c5f9ecd2f78a0a26d2f4013a622258f", + "sha256:c25e4fff73f64d20645254783c3224a4c49e083e3fab67c44f17af944c5e26af" ], "index": "pypi", "markers": "python_version ~= '3.7'", - "version": "==0.1.2" + "version": "==0.1.4" }, "async-timeout": { "hashes": [ @@ -119,12 +119,12 @@ }, "beautifulsoup4": { "hashes": [ - "sha256:73cc4d115b96f79c7d77c1c7f7a0a8d4c57860d1041df407dd1aae7f07a77fd7", - "sha256:a6237df3c32ccfaee4fd201c8f5f9d9df619b93121d01353a64a73ce8c6ef9a8", - "sha256:e718f2342e2e099b640a34ab782407b7b676f47ee272d6739e60b8ea23829f2c" + "sha256:1edf5e39f3a5bc6e38b235b369128416c7239b34f692acccececb040233032a1", + "sha256:5dfe44f8fddc89ac5453f02659d3ab1668f2c0d9684839f0785037e8c6d9ac8d", + "sha256:645d833a828722357038299b7f6879940c11dddd95b900fe5387c258b72bb883" ], "index": "pypi", - "version": "==4.9.1" + "version": "==4.9.2" }, "certifi": { "hashes": [ @@ -135,36 +135,44 @@ }, "cffi": { "hashes": [ - "sha256:0da50dcbccd7cb7e6c741ab7912b2eff48e85af217d72b57f80ebc616257125e", - "sha256:12a453e03124069b6896107ee133ae3ab04c624bb10683e1ed1c1663df17c13c", - "sha256:15419020b0e812b40d96ec9d369b2bc8109cc3295eac6e013d3261343580cc7e", - "sha256:15a5f59a4808f82d8ec7364cbace851df591c2d43bc76bcbe5c4543a7ddd1bf1", - "sha256:23e44937d7695c27c66a54d793dd4b45889a81b35c0751ba91040fe825ec59c4", - "sha256:29c4688ace466a365b85a51dcc5e3c853c1d283f293dfcc12f7a77e498f160d2", - "sha256:57214fa5430399dffd54f4be37b56fe22cedb2b98862550d43cc085fb698dc2c", - "sha256:577791f948d34d569acb2d1add5831731c59d5a0c50a6d9f629ae1cefd9ca4a0", - "sha256:6539314d84c4d36f28d73adc1b45e9f4ee2a89cdc7e5d2b0a6dbacba31906798", - "sha256:65867d63f0fd1b500fa343d7798fa64e9e681b594e0a07dc934c13e76ee28fb1", - "sha256:672b539db20fef6b03d6f7a14b5825d57c98e4026401fce838849f8de73fe4d4", - "sha256:6843db0343e12e3f52cc58430ad559d850a53684f5b352540ca3f1bc56df0731", - "sha256:7057613efefd36cacabbdbcef010e0a9c20a88fc07eb3e616019ea1692fa5df4", - "sha256:76ada88d62eb24de7051c5157a1a78fd853cca9b91c0713c2e973e4196271d0c", - "sha256:837398c2ec00228679513802e3744d1e8e3cb1204aa6ad408b6aff081e99a487", - "sha256:8662aabfeab00cea149a3d1c2999b0731e70c6b5bac596d95d13f643e76d3d4e", - "sha256:95e9094162fa712f18b4f60896e34b621df99147c2cee216cfa8f022294e8e9f", - "sha256:99cc66b33c418cd579c0f03b77b94263c305c389cb0c6972dac420f24b3bf123", - "sha256:9b219511d8b64d3fa14261963933be34028ea0e57455baf6781fe399c2c3206c", - "sha256:ae8f34d50af2c2154035984b8b5fc5d9ed63f32fe615646ab435b05b132ca91b", - "sha256:b9aa9d8818c2e917fa2c105ad538e222a5bce59777133840b93134022a7ce650", - "sha256:bf44a9a0141a082e89c90e8d785b212a872db793a0080c20f6ae6e2a0ebf82ad", - "sha256:c0b48b98d79cf795b0916c57bebbc6d16bb43b9fc9b8c9f57f4cf05881904c75", - "sha256:da9d3c506f43e220336433dffe643fbfa40096d408cb9b7f2477892f369d5f82", - "sha256:e4082d832e36e7f9b2278bc774886ca8207346b99f278e54c9de4834f17232f7", - "sha256:e4b9b7af398c32e408c00eb4e0d33ced2f9121fd9fb978e6c1b57edd014a7d15", - "sha256:e613514a82539fc48291d01933951a13ae93b6b444a88782480be32245ed4afa", - "sha256:f5033952def24172e60493b68717792e3aebb387a8d186c43c020d9363ee7281" - ], - "version": "==1.14.2" + "sha256:005f2bfe11b6745d726dbb07ace4d53f057de66e336ff92d61b8c7e9c8f4777d", + "sha256:09e96138280241bd355cd585148dec04dbbedb4f46128f340d696eaafc82dd7b", + "sha256:0b1ad452cc824665ddc682400b62c9e4f5b64736a2ba99110712fdee5f2505c4", + "sha256:0ef488305fdce2580c8b2708f22d7785ae222d9825d3094ab073e22e93dfe51f", + "sha256:15f351bed09897fbda218e4db5a3d5c06328862f6198d4fb385f3e14e19decb3", + "sha256:22399ff4870fb4c7ef19fff6eeb20a8bbf15571913c181c78cb361024d574579", + "sha256:23e5d2040367322824605bc29ae8ee9175200b92cb5483ac7d466927a9b3d537", + "sha256:2791f68edc5749024b4722500e86303a10d342527e1e3bcac47f35fbd25b764e", + "sha256:2f9674623ca39c9ebe38afa3da402e9326c245f0f5ceff0623dccdac15023e05", + "sha256:3363e77a6176afb8823b6e06db78c46dbc4c7813b00a41300a4873b6ba63b171", + "sha256:33c6cdc071ba5cd6d96769c8969a0531be2d08c2628a0143a10a7dcffa9719ca", + "sha256:3b8eaf915ddc0709779889c472e553f0d3e8b7bdf62dab764c8921b09bf94522", + "sha256:3cb3e1b9ec43256c4e0f8d2837267a70b0e1ca8c4f456685508ae6106b1f504c", + "sha256:3eeeb0405fd145e714f7633a5173318bd88d8bbfc3dd0a5751f8c4f70ae629bc", + "sha256:44f60519595eaca110f248e5017363d751b12782a6f2bd6a7041cba275215f5d", + "sha256:4d7c26bfc1ea9f92084a1d75e11999e97b62d63128bcc90c3624d07813c52808", + "sha256:529c4ed2e10437c205f38f3691a68be66c39197d01062618c55f74294a4a4828", + "sha256:6642f15ad963b5092d65aed022d033c77763515fdc07095208f15d3563003869", + "sha256:85ba797e1de5b48aa5a8427b6ba62cf69607c18c5d4eb747604b7302f1ec382d", + "sha256:8f0f1e499e4000c4c347a124fa6a27d37608ced4fe9f7d45070563b7c4c370c9", + "sha256:a624fae282e81ad2e4871bdb767e2c914d0539708c0f078b5b355258293c98b0", + "sha256:b0358e6fefc74a16f745afa366acc89f979040e0cbc4eec55ab26ad1f6a9bfbc", + "sha256:bbd2f4dfee1079f76943767fce837ade3087b578aeb9f69aec7857d5bf25db15", + "sha256:bf39a9e19ce7298f1bd6a9758fa99707e9e5b1ebe5e90f2c3913a47bc548747c", + "sha256:c11579638288e53fc94ad60022ff1b67865363e730ee41ad5e6f0a17188b327a", + "sha256:c150eaa3dadbb2b5339675b88d4573c1be3cb6f2c33a6c83387e10cc0bf05bd3", + "sha256:c53af463f4a40de78c58b8b2710ade243c81cbca641e34debf3396a9640d6ec1", + "sha256:cb763ceceae04803adcc4e2d80d611ef201c73da32d8f2722e9d0ab0c7f10768", + "sha256:cc75f58cdaf043fe6a7a6c04b3b5a0e694c6a9e24050967747251fb80d7bce0d", + "sha256:d80998ed59176e8cba74028762fbd9b9153b9afc71ea118e63bbf5d4d0f9552b", + "sha256:de31b5164d44ef4943db155b3e8e17929707cac1e5bd2f363e67a56e3af4af6e", + "sha256:e66399cf0fc07de4dce4f588fc25bfe84a6d1285cc544e67987d22663393926d", + "sha256:f0620511387790860b249b9241c2f13c3a80e21a73e0b861a2df24e9d6f56730", + "sha256:f4eae045e6ab2bb54ca279733fe4eb85f1effda392666308250714e01907f394", + "sha256:f92cdecb618e5fa4658aeb97d5eb3d2f47aa94ac6477c6daf0f306c5a3b9e6b1", + "sha256:f92f789e4f9241cd262ad7a555ca2c648a98178a953af117ef7fad46aa1d5591" + ], + "version": "==1.14.3" }, "chardet": { "hashes": [ @@ -575,11 +583,11 @@ }, "sentry-sdk": { "hashes": [ - "sha256:1a086486ff9da15791f294f6e9915eb3747d161ef64dee2d038a4d0b4a369b24", - "sha256:45486deb031cea6bbb25a540d7adb4dd48cd8a1cc31e6a5ce9fb4f792a572e9a" + "sha256:c9c0fa1412bad87104c4eee8dd36c7bbf60b0d92ae917ab519094779b22e6d9a", + "sha256:e159f7c919d19ae86e5a4ff370fccc45149fab461fbeb93fb5a735a0b33a9cb1" ], "index": "pypi", - "version": "==0.17.6" + "version": "==0.17.8" }, "six": { "hashes": [ @@ -608,7 +616,7 @@ "sha256:1634eea42ab371d3d346309b93df7870a88610f0725d47528be902a0d95ecc55", "sha256:a59dc181727e95d25f781f0eb4fd1825ff45590ec8ff49eadfd7f1a537cc0232" ], - "markers": "python_version >= '3.5'", + "markers": "python_version >= '3.0'", "version": "==2.0.1" }, "sphinx": { @@ -685,26 +693,26 @@ }, "yarl": { "hashes": [ - "sha256:040b237f58ff7d800e6e0fd89c8439b841f777dd99b4a9cca04d6935564b9409", - "sha256:17668ec6722b1b7a3a05cc0167659f6c95b436d25a36c2d52db0eca7d3f72593", - "sha256:3a584b28086bc93c888a6c2aa5c92ed1ae20932f078c46509a66dce9ea5533f2", - "sha256:4439be27e4eee76c7632c2427ca5e73703151b22cae23e64adb243a9c2f565d8", - "sha256:48e918b05850fffb070a496d2b5f97fc31d15d94ca33d3d08a4f86e26d4e7c5d", - "sha256:9102b59e8337f9874638fcfc9ac3734a0cfadb100e47d55c20d0dc6087fb4692", - "sha256:9b930776c0ae0c691776f4d2891ebc5362af86f152dd0da463a6614074cb1b02", - "sha256:b3b9ad80f8b68519cc3372a6ca85ae02cc5a8807723ac366b53c0f089db19e4a", - "sha256:bc2f976c0e918659f723401c4f834deb8a8e7798a71be4382e024bcc3f7e23a8", - "sha256:c22c75b5f394f3d47105045ea551e08a3e804dc7e01b37800ca35b58f856c3d6", - "sha256:c52ce2883dc193824989a9b97a76ca86ecd1fa7955b14f87bf367a61b6232511", - "sha256:ce584af5de8830d8701b8979b18fcf450cef9a382b1a3c8ef189bedc408faf1e", - "sha256:da456eeec17fa8aa4594d9a9f27c0b1060b6a75f2419fe0c00609587b2695f4a", - "sha256:db6db0f45d2c63ddb1a9d18d1b9b22f308e52c83638c26b422d520a815c4b3fb", - "sha256:df89642981b94e7db5596818499c4b2219028f2a528c9c37cc1de45bf2fd3a3f", - "sha256:f18d68f2be6bf0e89f1521af2b1bb46e66ab0018faafa81d70f358153170a317", - "sha256:f379b7f83f23fe12823085cd6b906edc49df969eb99757f58ff382349a3303c6" + "sha256:04a54f126a0732af75e5edc9addeaa2113e2ca7c6fce8974a63549a70a25e50e", + "sha256:3cc860d72ed989f3b1f3abbd6ecf38e412de722fb38b8f1b1a086315cf0d69c5", + "sha256:5d84cc36981eb5a8533be79d6c43454c8e6a39ee3118ceaadbd3c029ab2ee580", + "sha256:5e447e7f3780f44f890360ea973418025e8c0cdcd7d6a1b221d952600fd945dc", + "sha256:61d3ea3c175fe45f1498af868879c6ffeb989d4143ac542163c45538ba5ec21b", + "sha256:67c5ea0970da882eaf9efcf65b66792557c526f8e55f752194eff8ec722c75c2", + "sha256:6f6898429ec3c4cfbef12907047136fd7b9e81a6ee9f105b45505e633427330a", + "sha256:7ce35944e8e61927a8f4eb78f5bc5d1e6da6d40eadd77e3f79d4e9399e263921", + "sha256:b7c199d2cbaf892ba0f91ed36d12ff41ecd0dde46cbf64ff4bfe997a3ebc925e", + "sha256:c15d71a640fb1f8e98a1423f9c64d7f1f6a3a168f803042eaf3a5b5022fde0c1", + "sha256:c22607421f49c0cb6ff3ed593a49b6a99c6ffdeaaa6c944cdda83c2393c8864d", + "sha256:c604998ab8115db802cc55cb1b91619b2831a6128a62ca7eea577fc8ea4d3131", + "sha256:d088ea9319e49273f25b1c96a3763bf19a882cff774d1792ae6fba34bd40550a", + "sha256:db9eb8307219d7e09b33bcb43287222ef35cbcf1586ba9472b0a4b833666ada1", + "sha256:e31fef4e7b68184545c3d68baec7074532e077bd1906b040ecfba659737df188", + "sha256:e32f0fb443afcfe7f01f95172b66f279938fbc6bdaebe294b0ff6747fb6db020", + "sha256:fcbe419805c9b20db9a51d33b942feddbf6e7fb468cb20686fd7089d4164c12a" ], "markers": "python_version >= '3.5'", - "version": "==1.5.1" + "version": "==1.6.0" } }, "develop": { @@ -857,11 +865,11 @@ }, "identify": { "hashes": [ - "sha256:c770074ae1f19e08aadbda1c886bc6d0cb55ffdc503a8c0fe8699af2fc9664ae", - "sha256:d02d004568c5a01261839a05e91705e3e9f5c57a3551648f9b3fb2b9c62c0f62" + "sha256:7c22c384a2c9b32c5cc891d13f923f6b2653aa83e2d75d8f79be240d6c86c4f4", + "sha256:da683bfb7669fa749fc7731f378229e2dbf29a1d1337cbde04106f02236eb29d" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.5.3" + "version": "==1.5.5" }, "mccabe": { "hashes": [ -- cgit v1.2.3 From c2912658fc3ec6dd8881688fcd489b797a270b0f Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 26 Sep 2020 23:23:29 +0200 Subject: Verification: move disabled DM handling into helper Note that we were previously only catching 403. As the docstring explains, we will now catch any Discord exception and only look at the the code, rather than the status. --- bot/exts/moderation/verification.py | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index e9ab2c816..e10ad3e23 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -109,6 +109,25 @@ def is_verified(member: discord.Member) -> bool: return len(set(member.roles) - unverified_roles) > 0 +async def safe_dm(coro: t.Coroutine) -> None: + """ + Execute `coro` ignoring disabled DM warnings. + + The 50_0007 error code indicates that the target user does not accept DMs. + As it turns out, this error code can appear on both 400 and 403 statuses, + we therefore catch any Discord exception. + + If the request fails on any other error code, the exception propagates, + and must be handled by the caller. + """ + try: + await coro + except discord.HTTPException as discord_exc: + log.trace(f"DM dispatch failed on status {discord_exc.status} with code: {discord_exc.code}") + if discord_exc.code != 50_007: # If any reason other than disabled DMs + raise + + class Verification(Cog): """ User verification and role management. @@ -330,11 +349,9 @@ class Verification(Cog): async def kick_request(member: discord.Member) -> None: """Send `KICKED_MESSAGE` to `member` and kick them from the guild.""" try: - await member.send(KICKED_MESSAGE) - except discord.Forbidden as exc_403: - log.trace(f"DM dispatch failed on 403 error with code: {exc_403.code}") - if exc_403.code != 50_007: # 403 raised for any other reason than disabled DMs - raise StopExecution(reason=exc_403) + await safe_dm(member.send(KICKED_MESSAGE)) # Suppress disabled DMs + except discord.HTTPException as suspicious_exception: + raise StopExecution(reason=suspicious_exception) await member.kick(reason=f"User has not verified in {constants.Verification.kicked_after} days") n_kicked = await self._send_requests(members, kick_request, Limit(batch_size=2, sleep_secs=1)) -- cgit v1.2.3 From 6c069be09c4edee18b5853d990ffe1dff86ef9ce Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 26 Sep 2020 23:37:01 +0200 Subject: Verification: apply 'safe_dm' to all DM dispatches Now, when we send a DM and it fails: * Ignore if due to disabled DMs * Log exception otherwise --- bot/exts/moderation/verification.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index e10ad3e23..206556483 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -520,8 +520,10 @@ class Verification(Cog): return # Only listen for PyDis events log.trace(f"Sending on join message to new member: {member.id}") - with suppress(discord.Forbidden): - await member.send(ON_JOIN_MESSAGE) + try: + await safe_dm(member.send(ON_JOIN_MESSAGE)) + except discord.HTTPException: + log.exception("DM dispatch failed on unexpected error code") @Cog.listener() async def on_message(self, message: discord.Message) -> None: @@ -688,9 +690,9 @@ class Verification(Cog): await ctx.author.remove_roles(discord.Object(constants.Roles.unverified)) try: - await ctx.author.send(VERIFIED_MESSAGE) - except discord.Forbidden: - log.info(f"Sending welcome message failed for {ctx.author}.") + await safe_dm(ctx.author.send(VERIFIED_MESSAGE)) + except discord.HTTPException: + log.exception(f"Sending welcome message failed for {ctx.author}.") finally: log.trace(f"Deleting accept message by {ctx.author}.") with suppress(discord.NotFound): -- cgit v1.2.3 From 921198829a3339caf5e027ac893c0996815650f3 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sun, 27 Sep 2020 17:02:56 +0200 Subject: Allow !eval in #code-help-voice --- bot/constants.py | 1 + bot/exts/utils/snekbox.py | 2 +- config-default.yml | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/constants.py b/bot/constants.py index c710e2dff..d3794d173 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -391,6 +391,7 @@ class Channels(metaclass=YAMLGetter): big_brother_logs: int bot_commands: int change_log: int + code_help_voice: int cooldown: int defcon: int dev_contrib: int diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index b3baffba2..18b9a5014 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -41,7 +41,7 @@ RAW_CODE_REGEX = re.compile( MAX_PASTE_LEN = 1000 # `!eval` command whitelists -EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric) +EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric, Channels.code_help_voice) EVAL_CATEGORIES = (Categories.help_available, Categories.help_in_use) EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners) diff --git a/config-default.yml b/config-default.yml index e7669e6db..5112af95b 100644 --- a/config-default.yml +++ b/config-default.yml @@ -190,6 +190,7 @@ guild: admin_announcements: &ADMIN_ANNOUNCEMENTS 749736155569848370 # Voice + code_help_voice: 755154969761677312 admins_voice: &ADMINS_VOICE 500734494840717332 staff_voice: &STAFF_VOICE 412375055910043655 -- cgit v1.2.3 From 27f9e118d4f18cbfd4b64b28e6792b7fe4462523 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sun, 27 Sep 2020 17:08:27 +0200 Subject: Allow !role for any staff role Closes #1173 --- bot/exts/info/information.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 156dfec35..f6ed176f1 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -77,7 +77,7 @@ class Information(Cog): channel_type_list = sorted(channel_type_list) return "\n".join(channel_type_list) - @has_any_role(*constants.MODERATION_ROLES) + @has_any_role(*constants.STAFF_ROLES) @command(name="roles") async def roles_info(self, ctx: Context) -> None: """Returns a list of all roles and their corresponding IDs.""" @@ -97,7 +97,7 @@ class Information(Cog): await LinePaginator.paginate(role_list, ctx, embed, empty=False) - @has_any_role(*constants.MODERATION_ROLES) + @has_any_role(*constants.STAFF_ROLES) @command(name="role") async def role_info(self, ctx: Context, *roles: Union[Role, str]) -> None: """ -- cgit v1.2.3 From 27b666b65edfdd3294ce9bf58cc2736bf1437eb8 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sun, 27 Sep 2020 18:08:48 +0200 Subject: Incidents: reduce timeout log to info level This shouldn't be a warning, as we cannot do anything about it. Fixes BOT-8X --- bot/exts/moderation/incidents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 31be48a43..0e479d33f 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -319,7 +319,7 @@ class Incidents(Cog): try: await confirmation_task except asyncio.TimeoutError: - log.warning(f"Did not receive incident deletion confirmation within {timeout} seconds!") + log.info(f"Did not receive incident deletion confirmation within {timeout} seconds!") else: log.trace("Deletion was confirmed") -- cgit v1.2.3 From 56089920fb7ece152a97e6dc71968bb875c28c33 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Sun, 27 Sep 2020 22:51:28 +0530 Subject: modify tests to use paginated response. --- tests/bot/exts/backend/sync/test_users.py | 43 ++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/tests/bot/exts/backend/sync/test_users.py b/tests/bot/exts/backend/sync/test_users.py index c0a1da35c..4ebc8b82f 100644 --- a/tests/bot/exts/backend/sync/test_users.py +++ b/tests/bot/exts/backend/sync/test_users.py @@ -41,6 +41,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): return guild async def test_empty_diff_for_no_users(self): + # TODO: need to fix this test. """When no users are given, an empty diff should be returned.""" guild = self.get_guild() @@ -51,7 +52,12 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): async def test_empty_diff_for_identical_users(self): """No differences should be found if the users in the guild and DB are identical.""" - self.bot.api_client.get.return_value = [fake_user()] + self.bot.api_client.get.return_value = { + "count": 3, + "next": None, + "previous": None, + "results": [fake_user()] + } guild = self.get_guild(fake_user()) actual_diff = await self.syncer._get_diff(guild) @@ -63,7 +69,12 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): """Only updated users should be added to the 'updated' set of the diff.""" updated_user = fake_user(id=99, name="new") - self.bot.api_client.get.return_value = [fake_user(id=99, name="old"), fake_user()] + self.bot.api_client.get.return_value = { + "count": 3, + "next": None, + "previous": None, + "results": [fake_user(id=99, name="old"), fake_user()] + } guild = self.get_guild(updated_user, fake_user()) actual_diff = await self.syncer._get_diff(guild) @@ -75,7 +86,12 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): """Only new users should be added to the 'created' set of the diff.""" new_user = fake_user(id=99, name="new") - self.bot.api_client.get.return_value = [fake_user()] + self.bot.api_client.get.return_value = { + "count": 3, + "next": None, + "previous": None, + "results": [fake_user()] + } guild = self.get_guild(fake_user(), new_user) actual_diff = await self.syncer._get_diff(guild) @@ -87,7 +103,12 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): """When a user leaves the guild, the `in_guild` flag is updated to `False`.""" leaving_user = fake_user(id=63, in_guild=False) - self.bot.api_client.get.return_value = [fake_user(), fake_user(id=63)] + self.bot.api_client.get.return_value = { + "count": 3, + "next": None, + "previous": None, + "results": [fake_user(), fake_user(id=63)] + } guild = self.get_guild(fake_user()) actual_diff = await self.syncer._get_diff(guild) @@ -101,7 +122,12 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): updated_user = fake_user(id=55, name="updated") leaving_user = fake_user(id=63, in_guild=False) - self.bot.api_client.get.return_value = [fake_user(), fake_user(id=55), fake_user(id=63)] + self.bot.api_client.get.return_value = { + "count": 3, + "next": None, + "previous": None, + "results": [fake_user(), fake_user(id=55), fake_user(id=63)] + } guild = self.get_guild(fake_user(), new_user, updated_user) actual_diff = await self.syncer._get_diff(guild) @@ -111,7 +137,12 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): async def test_empty_diff_for_db_users_not_in_guild(self): """When the DB knows a user the guild doesn't, no difference is found.""" - self.bot.api_client.get.return_value = [fake_user(), fake_user(id=63, in_guild=False)] + self.bot.api_client.get.return_value = { + "count": 3, + "next": None, + "previous": None, + "results": [fake_user(), fake_user(id=63, in_guild=False)] + } guild = self.get_guild(fake_user()) actual_diff = await self.syncer._get_diff(guild) -- cgit v1.2.3 From f32a665cd0a03d8dbf4802643d32902c99bbd9ee Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Mon, 28 Sep 2020 23:41:48 +0530 Subject: Filter out reddit posts which are meant for users 18 years of older and send the rest. --- bot/exts/info/reddit.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/reddit.py b/bot/exts/info/reddit.py index 606c26aa7..f2aecc498 100644 --- a/bot/exts/info/reddit.py +++ b/bot/exts/info/reddit.py @@ -140,7 +140,12 @@ class Reddit(Cog): # Got appropriate response - process and return. content = await response.json() posts = content["data"]["children"] - if posts[0]["data"]["over_18"]: + + for post in posts: + if post["data"]["over_18"]: + posts.remove(post) + + if not posts: resp_not_allowed = [ { "error": "Oops ! Looks like this subreddit, doesn't fit in the scope of the server." -- cgit v1.2.3 From c90e50a81b1db63c12ef36af58d4cc04d035db2f Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 29 Sep 2020 10:36:48 +0200 Subject: Deps: bump 'discord.py' to 1.5 & re-lock This also removes a duplicate 'discord' entry from the lockfile. --- Pipfile | 2 +- Pipfile.lock | 25 ++++++++----------------- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/Pipfile b/Pipfile index e6f84d911..99fc70b46 100644 --- a/Pipfile +++ b/Pipfile @@ -14,7 +14,7 @@ beautifulsoup4 = "~=4.9" colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"} coloredlogs = "~=14.0" deepdiff = "~=4.0" -discord.py = "~=1.4.0" +"discord.py" = "~=1.5.0" feedparser = "~=5.2" fuzzywuzzy = "~=0.17" lxml = "~=4.4" diff --git a/Pipfile.lock b/Pipfile.lock index 4c63277de..becd85c55 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "644012a1c3fa3e3a30f8b8f8e672c468dfaa155d9e43d26e2be8713c8dc5ebb3" + "sha256": "073fd0c51749aafa188fdbe96c5b90dd157cb1d23bdd144801fb0d0a369ffa88" }, "pipfile-spec": 6, "requires": { @@ -18,11 +18,11 @@ "default": { "aio-pika": { "hashes": [ - "sha256:4a20d4d941e1f113a950ea529a90bd9159c8d7aafaa1c71e9c707c8c2b526ea6", - "sha256:7bf3f183df1eb348d007210a0c1a3c5c755f1b3def1a9a395e93f30b91da1daf" + "sha256:9773440a89840941ac3099a7720bf9d51e8764a484066b82ede4d395660ff430", + "sha256:a8065be3c722eb8f9fff8c0e7590729e7782202cdb9363d9830d7d5d47b45c7c" ], "index": "pypi", - "version": "==6.7.0" + "version": "==6.7.1" }, "aiodns": { "hashes": [ @@ -205,22 +205,13 @@ "index": "pypi", "version": "==4.3.2" }, - "discord": { - "hashes": [ - "sha256:9d4debb4a37845543bd4b92cb195bc53a302797333e768e70344222857ff1559", - "sha256:ff6653655e342e7721dfb3f10421345fd852c2a33f2cca912b1c39b3778a9429" - ], - "index": "pypi", - "py": "~=1.4.0", - "version": "==1.0.1" - }, "discord.py": { "hashes": [ - "sha256:98ea3096a3585c9c379209926f530808f5fcf4930928d8cfb579d2562d119570", - "sha256:f9decb3bfa94613d922376288617e6a6f969260923643e2897f4540c34793442" + "sha256:3acb61fde0d862ed346a191d69c46021e6063673f63963bc984ae09a685ab211", + "sha256:e71089886aa157341644bdecad63a72ff56b44406b1a6467b66db31c8e5a5a15" ], - "markers": "python_full_version >= '3.5.3'", - "version": "==1.4.1" + "index": "pypi", + "version": "==1.5.0" }, "docutils": { "hashes": [ -- cgit v1.2.3 From 7e9283260104999973301fe09859c75b87e62514 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Wed, 30 Sep 2020 20:17:22 +0200 Subject: Add intents setup to the bot --- bot/__main__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/bot/__main__.py b/bot/__main__.py index a07bc21d6..009f0ff27 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -47,6 +47,13 @@ loop.run_until_complete(redis_session.connect()) # Instantiate the bot. allowed_roles = [discord.Object(id_) for id_ in constants.MODERATION_ROLES] +intents = discord.Intents().all() +intents.presences = False +intents.dm_typing = False +intents.dm_reactions = False +intents.invites = False +intents.webhooks = False +intents.integrations = False bot = Bot( redis_session=redis_session, loop=loop, @@ -54,7 +61,8 @@ bot = Bot( activity=discord.Game(name="Commands: !help"), case_insensitive=True, max_messages=10_000, - allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) + allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles), + intents=intents ) # Load extensions. -- cgit v1.2.3 From 85e31b8d933900dde221d158cc27b08b923d53b3 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Wed, 30 Sep 2020 20:21:10 +0200 Subject: Remove Custom Status and Status from `create_user_embed` --- bot/exts/info/information.py | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index f6ed176f1..c9739dccd 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -211,25 +211,6 @@ class Information(Cog): """Creates an embed containing information on the `user`.""" created = time_since(user.created_at, max_units=3) - # Custom status - custom_status = '' - for activity in user.activities: - if isinstance(activity, CustomActivity): - state = "" - - if activity.name: - state = escape_markdown(activity.name) - - emoji = "" - if activity.emoji: - # If an emoji is unicode use the emoji, else write the emote like :abc: - if not activity.emoji.id: - emoji += activity.emoji.name + " " - else: - emoji += f"`:{activity.emoji.name}:` " - - custom_status = f'Status: {emoji}{state}\n' - name = str(user) if user.nick: name = f"{user.nick} ({name})" @@ -243,10 +224,6 @@ class Information(Cog): joined = time_since(user.joined_at, max_units=3) roles = ", ".join(role.mention for role in user.roles[1:]) - desktop_status = STATUS_EMOTES.get(user.desktop_status, constants.Emojis.status_online) - web_status = STATUS_EMOTES.get(user.web_status, constants.Emojis.status_online) - mobile_status = STATUS_EMOTES.get(user.mobile_status, constants.Emojis.status_online) - fields = [ ( "User information", @@ -254,7 +231,6 @@ class Information(Cog): Created: {created} Profile: {user.mention} ID: {user.id} - {custom_status} """).strip() ), ( @@ -264,14 +240,6 @@ class Information(Cog): Roles: {roles or None} """).strip() ), - ( - "Status", - textwrap.dedent(f""" - {desktop_status} Desktop - {web_status} Web - {mobile_status} Mobile - """).strip() - ) ] # Use getattr to future-proof for commands invoked via DMs. -- cgit v1.2.3 From d2fe88adb94c9dc3d84ff560c5246a023c72d9a8 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Wed, 30 Sep 2020 20:27:39 +0200 Subject: update member status info in `server` command --- bot/exts/info/information.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index c9739dccd..a50433c33 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -6,10 +6,9 @@ from collections import Counter, defaultdict from string import Template from typing import Any, Mapping, Optional, Tuple, Union -from discord import ChannelType, Colour, CustomActivity, Embed, Guild, Member, Message, Role, Status, utils +from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, utils from discord.abc import GuildChannel from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role -from discord.utils import escape_markdown from bot import constants from bot.bot import Bot @@ -153,7 +152,9 @@ class Information(Cog): channel_counts = self.get_channel_type_counts(ctx.guild) # How many of each user status? - statuses = Counter(member.status for member in ctx.guild.members) + py_invite = await self.bot.fetch_invite("python") + online_presences = py_invite.approximate_presence_count + offline_presences = ctx.guild.member_count - online_presences embed = Embed(colour=Colour.blurple()) # How many staff members and staff channels do we have? @@ -181,10 +182,8 @@ class Information(Cog): Roles: {roles} **Member statuses** - {constants.Emojis.status_online} {statuses[Status.online]:,} - {constants.Emojis.status_idle} {statuses[Status.idle]:,} - {constants.Emojis.status_dnd} {statuses[Status.dnd]:,} - {constants.Emojis.status_offline} {statuses[Status.offline]:,} + {constants.Emojis.status_online} {online_presences:,} + {constants.Emojis.status_offline} {offline_presences:,} """) ).substitute({"channel_counts": channel_counts}) embed.set_thumbnail(url=ctx.guild.icon_url) -- cgit v1.2.3 From ae29af7a85a2738d73c1b91689b42b8a22d7da6a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 30 Sep 2020 15:54:28 -0700 Subject: Remove alias cog Last few aliases are an anomaly since #1124 was merged. The remaining aliases are seldom used. The code isn't exactly clean and it has some maintenance costs. Resolves #1159 --- bot/exts/backend/alias.py | 87 ----------------------------------------------- 1 file changed, 87 deletions(-) delete mode 100644 bot/exts/backend/alias.py diff --git a/bot/exts/backend/alias.py b/bot/exts/backend/alias.py deleted file mode 100644 index c6ba8d6f3..000000000 --- a/bot/exts/backend/alias.py +++ /dev/null @@ -1,87 +0,0 @@ -import inspect -import logging - -from discord import Colour, Embed -from discord.ext.commands import ( - Cog, Command, Context, - clean_content, command, group, -) - -from bot.bot import Bot -from bot.converters import TagNameConverter -from bot.pagination import LinePaginator - -log = logging.getLogger(__name__) - - -class Alias (Cog): - """Aliases for commonly used commands.""" - - def __init__(self, bot: Bot): - self.bot = bot - - async def invoke(self, ctx: Context, cmd_name: str, *args, **kwargs) -> None: - """Invokes a command with args and kwargs.""" - log.debug(f"{cmd_name} was invoked through an alias") - cmd = self.bot.get_command(cmd_name) - if not cmd: - return log.info(f'Did not find command "{cmd_name}" to invoke.') - elif not await cmd.can_run(ctx): - return log.info( - f'{str(ctx.author)} tried to run the command "{cmd_name}" but lacks permission.' - ) - - await ctx.invoke(cmd, *args, **kwargs) - - @command(name='aliases') - async def aliases_command(self, ctx: Context) -> None: - """Show configured aliases on the bot.""" - embed = Embed( - title='Configured aliases', - colour=Colour.blue() - ) - await LinePaginator.paginate( - ( - f"• `{ctx.prefix}{value.name}` " - f"=> `{ctx.prefix}{name[:-len('_alias')].replace('_', ' ')}`" - for name, value in inspect.getmembers(self) - if isinstance(value, Command) and name.endswith('_alias') - ), - ctx, embed, empty=False, max_lines=20 - ) - - @command(name="exception", hidden=True) - async def tags_get_traceback_alias(self, ctx: Context) -> None: - """Alias for invoking tags get traceback.""" - await self.invoke(ctx, "tags get", tag_name="traceback") - - @group(name="get", - aliases=("show", "g"), - hidden=True, - invoke_without_command=True) - async def get_group_alias(self, ctx: Context) -> None: - """Group for reverse aliases for commands like `tags get`, allowing for `get tags` or `get docs`.""" - pass - - @get_group_alias.command(name="tags", aliases=("tag", "t"), hidden=True) - async def tags_get_alias( - self, ctx: Context, *, tag_name: TagNameConverter = None - ) -> None: - """ - Alias for invoking tags get [tag_name]. - - tag_name: str - tag to be viewed. - """ - await self.invoke(ctx, "tags get", tag_name=tag_name) - - @get_group_alias.command(name="docs", aliases=("doc", "d"), hidden=True) - async def docs_get_alias( - self, ctx: Context, symbol: clean_content = None - ) -> None: - """Alias for invoking docs get [symbol].""" - await self.invoke(ctx, "docs get", symbol) - - -def setup(bot: Bot) -> None: - """Load the Alias cog.""" - bot.add_cog(Alias(bot)) -- cgit v1.2.3 From fc0da38b15ce01f90219346cf6fc0cfec592c682 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 30 Sep 2020 16:11:00 -0700 Subject: Catch 404 in wait_for_deletion when reacting The message may be deleted before the bot gets a chance to react. Fixes #1181 --- bot/utils/messages.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 9cc0d8a34..d0b2342b3 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -34,7 +34,11 @@ async def wait_for_deletion( if attach_emojis: for emoji in deletion_emojis: - await message.add_reaction(emoji) + try: + await message.add_reaction(emoji) + except discord.NotFound: + log.trace(f"Aborting wait_for_deletion: message {message.id} deleted prematurely.") + return def check(reaction: discord.Reaction, user: discord.Member) -> bool: """Check that the deletion emoji is reacted by the appropriate user.""" -- cgit v1.2.3 From 998ecc6484ab6897310061f9d8b45cb9a534fb0f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 30 Sep 2020 16:19:29 -0700 Subject: Remove null chars before posting deleted messages Our API doesn't allow null characters in the content field. It may be present because of a self bot that is able to send such character. Fixes #1182 Fixes BOT-8E --- bot/exts/moderation/modlog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index 41ed46b69..b01de0ee3 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -63,7 +63,7 @@ class ModLog(Cog, name="ModLog"): 'id': message.id, 'author': message.author.id, 'channel_id': message.channel.id, - 'content': message.content, + 'content': message.content.replace("\0", ""), # Null chars cause 400. 'embeds': [embed.to_dict() for embed in message.embeds], 'attachments': attachment, } -- cgit v1.2.3 From 9322e89ba7d043f5525eca31c0dd785260788b44 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 30 Sep 2020 17:06:59 -0700 Subject: Duck pond: ignore reactions in DMs Also handle the channel not being found, which may be due to a cache issue or because it got deleted. Fixes #1183 Fixes BOT-8T --- bot/exts/fun/duck_pond.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index 6c2d22b9c..b146545a4 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -145,6 +145,10 @@ class DuckPond(Cog): amount of ducks specified in the config under duck_pond/threshold, it will send the message off to the duck pond. """ + # Ignore DMs. + if payload.guild_id is None: + return + # Was this reaction issued in a blacklisted channel? if payload.channel_id in constants.DuckPond.channel_blacklist: return @@ -154,6 +158,9 @@ class DuckPond(Cog): return channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id) + if channel is None: + return + message = await channel.fetch_message(payload.message_id) member = discord.utils.get(message.guild.members, id=payload.user_id) -- cgit v1.2.3 From f791bc32adceeb765638fd8cf2c849e6f642b345 Mon Sep 17 00:00:00 2001 From: Hedy Li Date: Thu, 1 Oct 2020 17:04:09 +0800 Subject: fix spelling typos in bot/ python files --- bot/exts/help_channels.py | 2 +- bot/exts/info/help.py | 2 +- bot/exts/info/information.py | 2 +- bot/exts/utils/bot.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/help_channels.py b/bot/exts/help_channels.py index 9e33a6aba..f5c9a5dd0 100644 --- a/bot/exts/help_channels.py +++ b/bot/exts/help_channels.py @@ -494,7 +494,7 @@ class HelpChannels(commands.Cog): If `options` are provided, the channel will be edited after the move is completed. This is the same order of operations that `discord.TextChannel.edit` uses. For information on available - options, see the documention on `discord.TextChannel.edit`. While possible, position-related + options, see the documentation on `discord.TextChannel.edit`. While possible, position-related options should be avoided, as it may interfere with the category move we perform. """ # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had. diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 99d503f5c..599c5d5c0 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -229,7 +229,7 @@ class CustomHelpCommand(HelpCommand): async def send_cog_help(self, cog: Cog) -> None: """Send help for a cog.""" - # sort commands by name, and remove any the user cant run or are hidden. + # sort commands by name, and remove any the user can't run or are hidden. commands_ = await self.filter_commands(cog.get_commands(), sort=True) embed = Embed() diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index f6ed176f1..719f43b14 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -161,7 +161,7 @@ class Information(Cog): staff_channel_count = self.get_staff_channel_count(ctx.guild) # Because channel_counts lacks leading whitespace, it breaks the dedent if it's inserted directly by the - # f-string. While this is correctly formated by Discord, it makes unit testing difficult. To keep the formatting + # f-string. While this is correctly formatted by Discord, it makes unit testing difficult. To keep the formatting # without joining a tuple of strings we can use a Template string to insert the already-formatted channel_counts # after the dedent is made. embed.description = Template( diff --git a/bot/exts/utils/bot.py b/bot/exts/utils/bot.py index 7ed487d47..ba1fd2a5c 100644 --- a/bot/exts/utils/bot.py +++ b/bot/exts/utils/bot.py @@ -130,7 +130,7 @@ class BotCog(Cog, name="Bot"): else: content = "".join(content[1:]) - # Strip it again to remove any leading whitespace. This is neccessary + # Strip it again to remove any leading whitespace. This is necessary # if the first line of the message looked like ```python old = content.strip() -- cgit v1.2.3 From d8fbeedb7ec42b387c7f32d15e45675f987f427b Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Thu, 1 Oct 2020 15:27:50 +0530 Subject: handling empty list error in get_top_posts() method and filter posts using list comprehension. --- bot/exts/info/reddit.py | 32 +++++++++----------------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/bot/exts/info/reddit.py b/bot/exts/info/reddit.py index f2aecc498..c6aecaa20 100644 --- a/bot/exts/info/reddit.py +++ b/bot/exts/info/reddit.py @@ -141,31 +141,14 @@ class Reddit(Cog): content = await response.json() posts = content["data"]["children"] - for post in posts: - if post["data"]["over_18"]: - posts.remove(post) - - if not posts: - resp_not_allowed = [ - { - "error": "Oops ! Looks like this subreddit, doesn't fit in the scope of the server." - } - ] - return resp_not_allowed - return posts[:amount] + filtered_posts = [post for post in posts if not post["data"]["over_18"]] + + return filtered_posts[:amount] await asyncio.sleep(3) log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}") - resp_failed = [ - { - "error": ( - "Sorry! We couldn't find any posts from that subreddit. " - "If this problem persists, please let us know." - ) - } - ] - return resp_failed # Failed to get appropriate response within allowed number of retries. + return list() async def get_top_posts(self, subreddit: Subreddit, time: str = "all", amount: int = 5) -> Embed: """ @@ -183,10 +166,13 @@ class Reddit(Cog): amount=amount, params={"t": time} ) - if "error" in posts[0]: + if not posts: embed.title = random.choice(ERROR_REPLIES) embed.colour = Colour.red() - embed.description = posts[0]["error"] + embed.description = ( + "Sorry! We couldn't find any SFW posts from that subreddit. " + "If this problem persists, please let us know." + ) return embed -- cgit v1.2.3 From 3554a57cdfd9904e180cbe1689e36fea9df4dfb3 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Thu, 1 Oct 2020 15:30:27 +0530 Subject: re-add comment. --- 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 c6aecaa20..0a49e53e7 100644 --- a/bot/exts/info/reddit.py +++ b/bot/exts/info/reddit.py @@ -148,7 +148,7 @@ class Reddit(Cog): await asyncio.sleep(3) log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}") - return list() + return list() # Failed to get appropriate response within allowed number of retries. async def get_top_posts(self, subreddit: Subreddit, time: str = "all", amount: int = 5) -> Embed: """ -- cgit v1.2.3 From ba4778d1d618b37ca190c921bcec571319e2914e Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Thu, 1 Oct 2020 15:55:55 +0530 Subject: remove redundant type hints and improve existing function annotations --- bot/exts/backend/sync/_syncers.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index 512efaa3d..ea0f2bcb6 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -353,9 +353,9 @@ class UserSyncer(Syncer): return _Diff(users_to_create, users_to_update, None) - async def _get_users(self, endpoint: str = "bot/users", query_params: dict = None) -> t.List[dict]: + async def _get_users(self, endpoint: str = "bot/users", query_params: list = None) -> t.List[dict]: """GET all users recursively.""" - users: list = [] + users = [] response: dict = await self.bot.api_client.get(endpoint, params=query_params) users.extend(response["results"]) @@ -363,11 +363,10 @@ class UserSyncer(Syncer): if (next_page_url := response["next"]) is not None: next_endpoint, query_params = self.get_endpoint(next_page_url) users.extend(await self._get_users(next_endpoint, query_params)) - return users @staticmethod - def get_endpoint(url: str) -> tuple: + def get_endpoint(url: str) -> t.Tuple[str, t.List[tuple]]: """Extract the API endpoint and query params from a URL.""" url = urlparse(url) @@ -380,9 +379,9 @@ class UserSyncer(Syncer): return endpoint, params @staticmethod - def patch_dict(user: _User) -> dict: + def patch_dict(user: _User) -> t.Dict[str, t.Union[int, str, tuple, bool]]: """Convert namedtuple to dict by omitting None values.""" - user_dict: dict = {} + user_dict = {} for field in user._fields: if (value := getattr(user, field)) is not None: user_dict[field] = value @@ -392,8 +391,9 @@ class UserSyncer(Syncer): """Synchronise the database with the user cache of `guild`.""" log.trace("Syncing created users...") if diff.created: - created: list = [user._asdict() for user in diff.created] + created = [user._asdict() for user in diff.created] await self.bot.api_client.post("bot/users", json=created) + log.trace("Syncing updated users...") if diff.updated: - updated: list = [self.patch_dict(user) for user in diff.updated] + updated = [self.patch_dict(user) for user in diff.updated] await self.bot.api_client.patch("bot/users/bulk_patch", json=updated) -- cgit v1.2.3 From aaeedc97fe7462093b06536f1f4aa7f1fa9c0919 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 1 Oct 2020 09:06:05 -0700 Subject: Duck pond: ignore reaction events from other guilds --- bot/exts/fun/duck_pond.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index b146545a4..82084ea88 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -145,8 +145,8 @@ class DuckPond(Cog): amount of ducks specified in the config under duck_pond/threshold, it will send the message off to the duck pond. """ - # Ignore DMs. - if payload.guild_id is None: + # Ignore other guilds and DMs. + if payload.guild_id != constants.Guild.id: return # Was this reaction issued in a blacklisted channel? @@ -182,7 +182,13 @@ class DuckPond(Cog): @Cog.listener() async def on_raw_reaction_remove(self, payload: RawReactionActionEvent) -> None: """Ensure that people don't remove the green checkmark from duck ponded messages.""" + # Ignore other guilds and DMs. + if payload.guild_id != constants.Guild.id: + return + channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id) + if channel is None: + return # Prevent the green checkmark from being removed if payload.emoji.name == "✅": -- cgit v1.2.3 From cf9d08ffcf65196162f984fecc9341052cc31abd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 1 Oct 2020 09:25:43 -0700 Subject: Remove special handling for the alias cog in the !source command It's obsolete code because the cog has been removed. --- bot/exts/info/source.py | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/bot/exts/info/source.py b/bot/exts/info/source.py index 205e0ba81..f79be36b0 100644 --- a/bot/exts/info/source.py +++ b/bot/exts/info/source.py @@ -66,14 +66,8 @@ class BotSource(commands.Cog): Raise BadArgument if `source_item` is a dynamically-created object (e.g. via internal eval). """ if isinstance(source_item, commands.Command): - if source_item.cog_name == "Alias": - cmd_name = source_item.callback.__name__.replace("_alias", "") - cmd = self.bot.get_command(cmd_name.replace("_", " ")) - src = cmd.callback.__code__ - filename = src.co_filename - else: - src = source_item.callback.__code__ - filename = src.co_filename + src = source_item.callback.__code__ + filename = src.co_filename elif isinstance(source_item, str): tags_cog = self.bot.get_cog("Tags") filename = tags_cog._cache[source_item]["location"] @@ -113,13 +107,7 @@ class BotSource(commands.Cog): title = "Help Command" description = source_object.__doc__.splitlines()[1] elif isinstance(source_object, commands.Command): - if source_object.cog_name == "Alias": - cmd_name = source_object.callback.__name__.replace("_alias", "") - cmd = self.bot.get_command(cmd_name.replace("_", " ")) - description = cmd.short_doc - else: - description = source_object.short_doc - + description = source_object.short_doc title = f"Command: {source_object.qualified_name}" elif isinstance(source_object, str): title = f"Tag: {source_object}" -- cgit v1.2.3 From cbd972cb26ae8fb23a1a70448b0ae48ed08d894b Mon Sep 17 00:00:00 2001 From: Soumitra Shewale Date: Fri, 2 Oct 2020 00:49:29 +0530 Subject: Escape markdown in faulty source commands Closes #1177 --- bot/exts/info/source.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/source.py b/bot/exts/info/source.py index f79be36b0..7746e0c67 100644 --- a/bot/exts/info/source.py +++ b/bot/exts/info/source.py @@ -2,7 +2,7 @@ import inspect from pathlib import Path from typing import Optional, Tuple, Union -from discord import Embed +from discord import Embed, utils from discord.ext import commands from bot.bot import Bot @@ -36,7 +36,7 @@ class SourceConverter(commands.Converter): return argument.lower() raise commands.BadArgument( - f"Unable to convert `{argument}` to valid command{', tag,' if show_tag else ''} or Cog." + f"Unable to convert `{utils.escape_markdown(argument)}` to valid command{', tag,' if show_tag else ''} or Cog." ) -- cgit v1.2.3 From 6267e534fe2fe028ca3fe75844f9f8d8dc2e34ba Mon Sep 17 00:00:00 2001 From: Soumitra Shewale Date: Fri, 2 Oct 2020 01:03:09 +0530 Subject: Linter I had flake8 turned off in my dpy env -_- --- bot/exts/info/source.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/source.py b/bot/exts/info/source.py index 7746e0c67..f2412a8dd 100644 --- a/bot/exts/info/source.py +++ b/bot/exts/info/source.py @@ -36,7 +36,8 @@ class SourceConverter(commands.Converter): return argument.lower() raise commands.BadArgument( - f"Unable to convert `{utils.escape_markdown(argument)}` to valid command{', tag,' if show_tag else ''} or Cog." + f"Unable to convert `{utils.escape_markdown(argument)}` to valid\ + command{', tag,' if show_tag else ''} or Cog." ) -- cgit v1.2.3 From 28f2916f698ffcd1fe2c9d2cda86a180307980ef Mon Sep 17 00:00:00 2001 From: Soumitra Shewale Date: Fri, 2 Oct 2020 01:15:31 +0530 Subject: Move PEP command embed URL to title Closes #1176 --- 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 6b6941064..566058435 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -84,7 +84,7 @@ class Utils(Cog): # Assemble the embed pep_embed = Embed( title=f"**PEP {pep_number} - {pep_header['Title']}**", - description=f"[Link]({self.base_pep_url}{pep_number:04})", + url=f"{self.base_pep_url}{pep_number:04}" ) pep_embed.set_thumbnail(url=ICON_URL) -- cgit v1.2.3 From bb423b8105be2b9b5b843ee2661c4ff18be741e0 Mon Sep 17 00:00:00 2001 From: Hedy Li Date: Fri, 2 Oct 2020 06:05:01 +0000 Subject: fix line length in bot/exts/info/information.py --- bot/exts/info/information.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 719f43b14..52239c19e 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -161,9 +161,9 @@ class Information(Cog): staff_channel_count = self.get_staff_channel_count(ctx.guild) # Because channel_counts lacks leading whitespace, it breaks the dedent if it's inserted directly by the - # f-string. While this is correctly formatted by Discord, it makes unit testing difficult. To keep the formatting - # without joining a tuple of strings we can use a Template string to insert the already-formatted channel_counts - # after the dedent is made. + # f-string. While this is correctly formatted by Discord, it makes unit testing difficult. To keep the + # formatting without joining a tuple of strings we can use a Template string to insert the already-formatted + # channel_counts after the dedent is made. embed.description = Template( textwrap.dedent(f""" **Server information** -- cgit v1.2.3 From 4e695bc1c8ea48056e4fe155fca2c518c50277a9 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Fri, 2 Oct 2020 08:27:15 +0200 Subject: Remove failing unit tests Testing `information` cog seems redutant as it is not too important part of the bot. --- tests/bot/exts/info/test_information.py | 78 --------------------------------- 1 file changed, 78 deletions(-) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index d3f2995fb..83fc6d188 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -97,79 +97,6 @@ class InformationCogTests(unittest.TestCase): self.assertEqual(admin_embed.title, "Admins info") self.assertEqual(admin_embed.colour, discord.Colour.red()) - @unittest.mock.patch('bot.exts.info.information.time_since') - def test_server_info_command(self, time_since_patch): - time_since_patch.return_value = '2 days ago' - - self.ctx.guild = helpers.MockGuild( - features=('lemons', 'apples'), - region="The Moon", - roles=[self.moderator_role], - channels=[ - discord.TextChannel( - state={}, - guild=self.ctx.guild, - data={'id': 42, 'name': 'lemons-offering', 'position': 22, 'type': 'text'} - ), - discord.CategoryChannel( - state={}, - guild=self.ctx.guild, - data={'id': 5125, 'name': 'the-lemon-collection', 'position': 22, 'type': 'category'} - ), - discord.VoiceChannel( - state={}, - guild=self.ctx.guild, - data={'id': 15290, 'name': 'listen-to-lemon', 'position': 22, 'type': 'voice'} - ) - ], - members=[ - *(helpers.MockMember(status=discord.Status.online) for _ in range(2)), - *(helpers.MockMember(status=discord.Status.idle) for _ in range(1)), - *(helpers.MockMember(status=discord.Status.dnd) for _ in range(4)), - *(helpers.MockMember(status=discord.Status.offline) for _ in range(3)), - ], - member_count=1_234, - icon_url='a-lemon.jpg', - ) - - coroutine = self.cog.server_info.callback(self.cog, self.ctx) - self.assertIsNone(asyncio.run(coroutine)) - - time_since_patch.assert_called_once_with(self.ctx.guild.created_at, precision='days') - _, kwargs = self.ctx.send.call_args - embed = kwargs.pop('embed') - self.assertEqual(embed.colour, discord.Colour.blurple()) - self.assertEqual( - embed.description, - textwrap.dedent( - f""" - **Server information** - Created: {time_since_patch.return_value} - Voice region: {self.ctx.guild.region} - Features: {', '.join(self.ctx.guild.features)} - - **Channel counts** - Category channels: 1 - Text channels: 1 - Voice channels: 1 - Staff channels: 0 - - **Member counts** - Members: {self.ctx.guild.member_count:,} - Staff members: 0 - Roles: {len(self.ctx.guild.roles)} - - **Member statuses** - {constants.Emojis.status_online} 2 - {constants.Emojis.status_idle} 1 - {constants.Emojis.status_dnd} 4 - {constants.Emojis.status_offline} 3 - """ - ) - ) - self.assertEqual(embed.thumbnail.url, 'a-lemon.jpg') - - class UserInfractionHelperMethodTests(unittest.TestCase): """Tests for the helper methods of the `!user` command.""" @@ -465,11 +392,6 @@ class UserEmbedTests(unittest.TestCase): embed.fields[1].value ) - self.assertEqual( - "basic infractions info", - embed.fields[3].value - ) - @unittest.mock.patch( f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) -- cgit v1.2.3 From 93ce90d28e7a8314dbbc34600ab5b1bc89476b4b Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Fri, 2 Oct 2020 08:27:34 +0200 Subject: Remove presence stat tracking. --- bot/exts/info/stats.py | 37 ++----------------------------------- 1 file changed, 2 insertions(+), 35 deletions(-) diff --git a/bot/exts/info/stats.py b/bot/exts/info/stats.py index d42f55466..21aa91873 100644 --- a/bot/exts/info/stats.py +++ b/bot/exts/info/stats.py @@ -1,12 +1,11 @@ import string -from datetime import datetime -from discord import Member, Message, Status +from discord import Member, Message from discord.ext.commands import Cog, Context from discord.ext.tasks import loop from bot.bot import Bot -from bot.constants import Categories, Channels, Guild, Stats as StatConf +from bot.constants import Categories, Channels, Guild CHANNEL_NAME_OVERRIDES = { @@ -79,38 +78,6 @@ class Stats(Cog): self.bot.stats.gauge("guild.total_members", len(member.guild.members)) - @Cog.listener() - async def on_member_update(self, _before: Member, after: Member) -> None: - """Update presence estimates on member update.""" - if after.guild.id != Guild.id: - return - - if self.last_presence_update: - if (datetime.now() - self.last_presence_update).seconds < StatConf.presence_update_timeout: - return - - self.last_presence_update = datetime.now() - - online = 0 - idle = 0 - dnd = 0 - offline = 0 - - for member in after.guild.members: - if member.status is Status.online: - online += 1 - elif member.status is Status.dnd: - dnd += 1 - elif member.status is Status.idle: - idle += 1 - elif member.status is Status.offline: - offline += 1 - - self.bot.stats.gauge("guild.status.online", online) - self.bot.stats.gauge("guild.status.idle", idle) - self.bot.stats.gauge("guild.status.do_not_disturb", dnd) - self.bot.stats.gauge("guild.status.offline", offline) - @loop(hours=1) async def update_guild_boost(self) -> None: """Post the server boost level and tier every hour.""" -- cgit v1.2.3 From b87f3163ab05556c82cfe3d826aded68efa5ade4 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Fri, 2 Oct 2020 08:35:04 +0200 Subject: Add missing blank line to satisfy the linting gods --- tests/bot/exts/info/test_information.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index 83fc6d188..23eeb88cd 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -97,6 +97,7 @@ class InformationCogTests(unittest.TestCase): self.assertEqual(admin_embed.title, "Admins info") self.assertEqual(admin_embed.colour, discord.Colour.red()) + class UserInfractionHelperMethodTests(unittest.TestCase): """Tests for the helper methods of the `!user` command.""" -- cgit v1.2.3 From 2b956b25bedae7cd8fd24109ee73c3996fad8ccb Mon Sep 17 00:00:00 2001 From: Soumitra Shewale Date: Fri, 2 Oct 2020 15:05:15 +0530 Subject: Update !pep 0 command --- 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 566058435..3e9230414 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -250,7 +250,7 @@ class Utils(Cog): """Send information about PEP 0.""" pep_embed = Embed( title="**PEP 0 - Index of Python Enhancement Proposals (PEPs)**", - description="[Link](https://www.python.org/dev/peps/)" + url="https://www.python.org/dev/peps/" ) pep_embed.set_thumbnail(url=ICON_URL) pep_embed.add_field(name="Status", value="Active") -- cgit v1.2.3 From 0d3d7822c84d798a639df0bde348a256977db08a Mon Sep 17 00:00:00 2001 From: Soumitra Shewale Date: Fri, 2 Oct 2020 17:52:52 +0530 Subject: Get rid of codeblock in souce commit Double backtick will break if argument contains a double backtick, so getting rid of the codeblock itself makes more sense in my opionion. Also fix the style issue with multiline string by storing the escaped arg in another variable --- bot/exts/info/source.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/source.py b/bot/exts/info/source.py index f2412a8dd..7b41352d4 100644 --- a/bot/exts/info/source.py +++ b/bot/exts/info/source.py @@ -35,9 +35,10 @@ class SourceConverter(commands.Converter): elif argument.lower() in tags_cog._cache: return argument.lower() + escaped_arg = utils.escape_markdown(argument) + raise commands.BadArgument( - f"Unable to convert `{utils.escape_markdown(argument)}` to valid\ - command{', tag,' if show_tag else ''} or Cog." + f"Unable to convert '{escaped_arg}' to valid command{', tag,' if show_tag else ''} or Cog." ) -- cgit v1.2.3 From 10a65fee8b843990a87ab468c924e9f6cd4493d1 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Fri, 2 Oct 2020 16:38:26 +0200 Subject: Reminder: no feedback message when no mention --- bot/exts/utils/reminders.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 6806f2889..6fdb0b8ea 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -286,10 +286,11 @@ class Reminders(Cog): now = datetime.utcnow() - timedelta(seconds=1) humanized_delta = humanize_delta(relativedelta(expiration, now)) - mention_string = ( - f"Your reminder will arrive in {humanized_delta} " - f"and will mention {len(mentions)} other(s)!" - ) + mention_string = f"Your reminder will arrive in {humanized_delta}" + + if mentions: + mention_string += f" and will mention {len(mentions)} other(s)" + mention_string += "!" # Confirm to the user that it worked. await self._send_confirmation( -- cgit v1.2.3 From 0820a81057a8945f33cb386e2010ed78102c9c42 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Fri, 2 Oct 2020 21:39:25 +0530 Subject: update UserSyncerDiffTests Tests to use changes made to API calls. --- tests/bot/exts/backend/sync/test_users.py | 38 ++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/tests/bot/exts/backend/sync/test_users.py b/tests/bot/exts/backend/sync/test_users.py index 4ebc8b82f..e60c3a24d 100644 --- a/tests/bot/exts/backend/sync/test_users.py +++ b/tests/bot/exts/backend/sync/test_users.py @@ -16,6 +16,16 @@ def fake_user(**kwargs): return kwargs +def fake_none_user(**kwargs): + kwargs.setdefault("id", None) + kwargs.setdefault("name", None) + kwargs.setdefault("discriminator", None) + kwargs.setdefault("roles", None) + kwargs.setdefault("in_guild", None) + + return kwargs + + class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): """Tests for determining differences between users in the DB and users in the Guild cache.""" @@ -41,8 +51,13 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): return guild async def test_empty_diff_for_no_users(self): - # TODO: need to fix this test. """When no users are given, an empty diff should be returned.""" + self.bot.api_client.get.return_value = { + "count": 3, + "next": None, + "previous": None, + "results": [] + } guild = self.get_guild() actual_diff = await self.syncer._get_diff(guild) @@ -68,6 +83,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): async def test_diff_for_updated_users(self): """Only updated users should be added to the 'updated' set of the diff.""" updated_user = fake_user(id=99, name="new") + updated_user_none = fake_none_user(id=99, name="new") self.bot.api_client.get.return_value = { "count": 3, @@ -78,7 +94,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): guild = self.get_guild(updated_user, fake_user()) actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), {_User(**updated_user)}, None) + expected_diff = (set(), {_User(**updated_user_none)}, None) self.assertEqual(actual_diff, expected_diff) @@ -101,7 +117,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): async def test_diff_sets_in_guild_false_for_leaving_users(self): """When a user leaves the guild, the `in_guild` flag is updated to `False`.""" - leaving_user = fake_user(id=63, in_guild=False) + leaving_user_none = fake_none_user(id=63, in_guild=False) self.bot.api_client.get.return_value = { "count": 3, @@ -112,15 +128,18 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): guild = self.get_guild(fake_user()) actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), {_User(**leaving_user)}, None) + expected_diff = (set(), {_User(**leaving_user_none)}, None) self.assertEqual(actual_diff, expected_diff) async def test_diff_for_new_updated_and_leaving_users(self): """When users are added, updated, and removed, all of them are returned properly.""" new_user = fake_user(id=99, name="new") + updated_user = fake_user(id=55, name="updated") - leaving_user = fake_user(id=63, in_guild=False) + updated_user_none = fake_none_user(id=55, name="updated") + + leaving_user_none = fake_none_user(id=63, in_guild=False) self.bot.api_client.get.return_value = { "count": 3, @@ -131,7 +150,14 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): guild = self.get_guild(fake_user(), new_user, updated_user) actual_diff = await self.syncer._get_diff(guild) - expected_diff = ({_User(**new_user)}, {_User(**updated_user), _User(**leaving_user)}, None) + expected_diff = ( + {_User(**new_user)}, + { + _User(**updated_user_none), + _User(**leaving_user_none) + }, + None + ) self.assertEqual(actual_diff, expected_diff) -- cgit v1.2.3 From 20c85e6fc46ab34fdce23e393a12e275a82a25fa Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Fri, 2 Oct 2020 23:37:15 +0530 Subject: Refactor unit tests UserSyncerSyncTests to use changes made to UserSyncer in _syncers.py --- tests/bot/exts/backend/sync/test_users.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tests/bot/exts/backend/sync/test_users.py b/tests/bot/exts/backend/sync/test_users.py index e60c3a24d..c3a486743 100644 --- a/tests/bot/exts/backend/sync/test_users.py +++ b/tests/bot/exts/backend/sync/test_users.py @@ -1,5 +1,4 @@ import unittest -from unittest import mock from bot.exts.backend.sync._syncers import UserSyncer, _Diff, _User from tests import helpers @@ -192,9 +191,9 @@ class UserSyncerSyncTests(unittest.IsolatedAsyncioTestCase): diff = _Diff(user_tuples, set(), None) await self.syncer._sync(diff) - calls = [mock.call("bot/users", json=user) for user in users] - self.bot.api_client.post.assert_has_calls(calls, any_order=True) - self.assertEqual(self.bot.api_client.post.call_count, len(users)) + # Convert namedtuples to dicts as done in self.syncer._sync method. + created = [user._asdict() for user in diff.created] + self.bot.api_client.post.assert_called_once_with("bot/users", json=created) self.bot.api_client.put.assert_not_called() self.bot.api_client.delete.assert_not_called() @@ -207,9 +206,8 @@ class UserSyncerSyncTests(unittest.IsolatedAsyncioTestCase): diff = _Diff(set(), user_tuples, None) await self.syncer._sync(diff) - calls = [mock.call(f"bot/users/{user['id']}", json=user) for user in users] - self.bot.api_client.put.assert_has_calls(calls, any_order=True) - self.assertEqual(self.bot.api_client.put.call_count, len(users)) + updated = [self.syncer.patch_dict(user) for user in diff.updated] + self.bot.api_client.patch.assert_called_once_with("bot/users/bulk_patch", json=updated) self.bot.api_client.post.assert_not_called() self.bot.api_client.delete.assert_not_called() -- cgit v1.2.3 From 0f2acbc651b400c29ebdabdfab7f6f7e2debe68e Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Fri, 2 Oct 2020 23:48:46 +0530 Subject: remove un-used variable --- bot/exts/backend/sync/_syncers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index 759af96d7..ae7d5d893 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -2,7 +2,6 @@ import abc import logging import typing as t from collections import namedtuple -from functools import partial from urllib.parse import parse_qsl, urlparse from discord import Guild -- cgit v1.2.3 From c035a756bac9f2d4c24dc232bda3a6d46b0c8a0f Mon Sep 17 00:00:00 2001 From: wookie184 Date: Fri, 2 Oct 2020 19:52:23 +0100 Subject: Changed send_attachments so kwargs could be given and would be passed to send() --- bot/utils/messages.py | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index d0b2342b3..c4ac1e360 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -56,15 +56,22 @@ async def wait_for_deletion( async def send_attachments( message: discord.Message, destination: Union[discord.TextChannel, discord.Webhook], - link_large: bool = True + link_large: bool = True, + **kwargs ) -> List[str]: """ Re-upload the message's attachments to the destination and return a list of their new URLs. Each attachment is sent as a separate message to more easily comply with the request/file size limit. If link_large is True, attachments which are too large are instead grouped into a single - embed which links to them. + embed which links to them. Extra kwargs will be passed to send() when sending the attachment. """ + webhook_send_kwargs = { + 'username': sub_clyde(message.author.display_name), + 'avatar_url': message.author.avatar_url, + } + webhook_send_kwargs.update(kwargs) + large = [] urls = [] for attachment in message.attachments: @@ -82,14 +89,10 @@ async def send_attachments( attachment_file = discord.File(file, filename=attachment.filename) if isinstance(destination, discord.TextChannel): - msg = await destination.send(file=attachment_file) + msg = await destination.send(file=attachment_file, **kwargs) urls.append(msg.attachments[0].url) else: - await destination.send( - file=attachment_file, - username=sub_clyde(message.author.display_name), - avatar_url=message.author.avatar_url - ) + await destination.send(file=attachment_file, **webhook_send_kwargs) elif link_large: large.append(attachment) else: @@ -106,13 +109,9 @@ async def send_attachments( embed.set_footer(text="Attachments exceed upload size limit.") if isinstance(destination, discord.TextChannel): - await destination.send(embed=embed) + await destination.send(embed=embed, **kwargs) else: - await destination.send( - embed=embed, - username=sub_clyde(message.author.display_name), - avatar_url=message.author.avatar_url - ) + await destination.send(embed=embed, **webhook_send_kwargs) return urls -- cgit v1.2.3 From f7015232947198f2a3d05c680df0da0bfaff4a8e Mon Sep 17 00:00:00 2001 From: wookie184 Date: Fri, 2 Oct 2020 19:55:06 +0100 Subject: Add use_cached argument to send_attachments, and change it to default to False --- bot/utils/messages.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index c4ac1e360..9fd571a20 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -57,6 +57,7 @@ async def send_attachments( message: discord.Message, destination: Union[discord.TextChannel, discord.Webhook], link_large: bool = True, + use_cached: bool = False, **kwargs ) -> List[str]: """ @@ -85,7 +86,7 @@ async def send_attachments( # but some may get through hence the try-catch. if attachment.size <= destination.guild.filesize_limit - 512: with BytesIO() as file: - await attachment.save(file, use_cached=True) + await attachment.save(file, use_cached=use_cached) attachment_file = discord.File(file, filename=attachment.filename) if isinstance(destination, discord.TextChannel): -- cgit v1.2.3 From 1481d8feaa4c155e13da2b1c5f9f9544d89e90c4 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Fri, 2 Oct 2020 19:57:07 +0100 Subject: Changed dm_relay to include user id in webhook when sending attachments. --- bot/exts/moderation/dm_relay.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/dm_relay.py b/bot/exts/moderation/dm_relay.py index 14263e004..4d5142b55 100644 --- a/bot/exts/moderation/dm_relay.py +++ b/bot/exts/moderation/dm_relay.py @@ -90,7 +90,11 @@ class DMRelay(Cog): # Handle any attachments if message.attachments: try: - await send_attachments(message, self.webhook) + await send_attachments( + message, + self.webhook, + username=f"{message.author.display_name} ({message.author.id})" + ) except (discord.errors.Forbidden, discord.errors.NotFound): e = discord.Embed( description=":x: **This message contained an attachment, but it could not be retrieved**", -- cgit v1.2.3 From 925219dec3ba199718ac0504cfc7f8b3e6917a1f Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 3 Oct 2020 11:36:19 +0100 Subject: Add a socket stats command --- bot/exts/utils/eval.py | 226 --------------------------------------- bot/exts/utils/internal.py | 258 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+), 226 deletions(-) delete mode 100644 bot/exts/utils/eval.py create mode 100644 bot/exts/utils/internal.py diff --git a/bot/exts/utils/eval.py b/bot/exts/utils/eval.py deleted file mode 100644 index 6419b320e..000000000 --- a/bot/exts/utils/eval.py +++ /dev/null @@ -1,226 +0,0 @@ -import contextlib -import inspect -import logging -import pprint -import re -import textwrap -import traceback -from io import StringIO -from typing import Any, Optional, Tuple - -import discord -from discord.ext.commands import Cog, Context, group, has_any_role - -from bot.bot import Bot -from bot.constants import Roles -from bot.interpreter import Interpreter -from bot.utils import find_nth_occurrence, send_to_paste_service - -log = logging.getLogger(__name__) - - -class CodeEval(Cog): - """Owner and admin feature that evaluates code and returns the result to the channel.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.env = {} - self.ln = 0 - self.stdout = StringIO() - - self.interpreter = Interpreter(bot) - - def _format(self, inp: str, out: Any) -> Tuple[str, Optional[discord.Embed]]: - """Format the eval output into a string & attempt to format it into an Embed.""" - self._ = out - - res = "" - - # Erase temp input we made - if inp.startswith("_ = "): - inp = inp[4:] - - # Get all non-empty lines - lines = [line for line in inp.split("\n") if line.strip()] - if len(lines) != 1: - lines += [""] - - # Create the input dialog - for i, line in enumerate(lines): - if i == 0: - # Start dialog - start = f"In [{self.ln}]: " - - else: - # Indent the 3 dots correctly; - # Normally, it's something like - # In [X]: - # ...: - # - # But if it's - # In [XX]: - # ...: - # - # You can see it doesn't look right. - # This code simply indents the dots - # far enough to align them. - # we first `str()` the line number - # then we get the length - # and use `str.rjust()` - # to indent it. - start = "...: ".rjust(len(str(self.ln)) + 7) - - if i == len(lines) - 2: - if line.startswith("return"): - line = line[6:].strip() - - # Combine everything - res += (start + line + "\n") - - self.stdout.seek(0) - text = self.stdout.read() - self.stdout.close() - self.stdout = StringIO() - - if text: - res += (text + "\n") - - if out is None: - # No output, return the input statement - return (res, None) - - res += f"Out[{self.ln}]: " - - if isinstance(out, discord.Embed): - # We made an embed? Send that as embed - res += "" - res = (res, out) - - else: - if (isinstance(out, str) and out.startswith("Traceback (most recent call last):\n")): - # Leave out the traceback message - out = "\n" + "\n".join(out.split("\n")[1:]) - - if isinstance(out, str): - pretty = out - else: - pretty = pprint.pformat(out, compact=True, width=60) - - if pretty != str(out): - # We're using the pretty version, start on the next line - res += "\n" - - if pretty.count("\n") > 20: - # Text too long, shorten - li = pretty.split("\n") - - pretty = ("\n".join(li[:3]) # First 3 lines - + "\n ...\n" # Ellipsis to indicate removed lines - + "\n".join(li[-3:])) # last 3 lines - - # Add the output - res += pretty - res = (res, None) - - return res # Return (text, embed) - - async def _eval(self, ctx: Context, code: str) -> Optional[discord.Message]: - """Eval the input code string & send an embed to the invoking context.""" - self.ln += 1 - - if code.startswith("exit"): - self.ln = 0 - self.env = {} - return await ctx.send("```Reset history!```") - - env = { - "message": ctx.message, - "author": ctx.message.author, - "channel": ctx.channel, - "guild": ctx.guild, - "ctx": ctx, - "self": self, - "bot": self.bot, - "inspect": inspect, - "discord": discord, - "contextlib": contextlib - } - - self.env.update(env) - - # Ignore this code, it works - code_ = """ -async def func(): # (None,) -> Any - try: - with contextlib.redirect_stdout(self.stdout): -{0} - if '_' in locals(): - if inspect.isawaitable(_): - _ = await _ - return _ - finally: - self.env.update(locals()) -""".format(textwrap.indent(code, ' ')) - - try: - exec(code_, self.env) # noqa: B102,S102 - func = self.env['func'] - res = await func() - - except Exception: - res = traceback.format_exc() - - out, embed = self._format(code, res) - out = out.rstrip("\n") # Strip empty lines from output - - # Truncate output to max 15 lines or 1500 characters - newline_truncate_index = find_nth_occurrence(out, "\n", 15) - - if newline_truncate_index is None or newline_truncate_index > 1500: - truncate_index = 1500 - else: - truncate_index = newline_truncate_index - - if len(out) > truncate_index: - paste_link = await send_to_paste_service(self.bot.http_session, out, extension="py") - if paste_link is not None: - paste_text = f"full contents at {paste_link}" - else: - paste_text = "failed to upload contents to paste service." - - await ctx.send( - f"```py\n{out[:truncate_index]}\n```" - f"... response truncated; {paste_text}", - embed=embed - ) - return - - await ctx.send(f"```py\n{out}```", embed=embed) - - @group(name='internal', aliases=('int',)) - @has_any_role(Roles.owners, Roles.admins) - async def internal_group(self, ctx: Context) -> None: - """Internal commands. Top secret!""" - if not ctx.invoked_subcommand: - await ctx.send_help(ctx.command) - - @internal_group.command(name='eval', aliases=('e',)) - @has_any_role(Roles.admins, Roles.owners) - async def eval(self, ctx: Context, *, code: str) -> None: - """Run eval in a REPL-like format.""" - code = code.strip("`") - if re.match('py(thon)?\n', code): - code = "\n".join(code.split("\n")[1:]) - - if not re.search( # Check if it's an expression - r"^(return|import|for|while|def|class|" - r"from|exit|[a-zA-Z0-9]+\s*=)", code, re.M) and len( - code.split("\n")) == 1: - code = "_ = " + code - - await self._eval(ctx, code) - - -def setup(bot: Bot) -> None: - """Load the CodeEval cog.""" - bot.add_cog(CodeEval(bot)) diff --git a/bot/exts/utils/internal.py b/bot/exts/utils/internal.py new file mode 100644 index 000000000..d61200575 --- /dev/null +++ b/bot/exts/utils/internal.py @@ -0,0 +1,258 @@ +import contextlib +import inspect +import logging +import pprint +import re +import textwrap +import traceback +from collections import Counter +from datetime import datetime +from io import StringIO +from typing import Any, Optional, Tuple + +import discord +from discord.ext.commands import Cog, Context, group, has_any_role + +from bot.bot import Bot +from bot.constants import Roles +from bot.interpreter import Interpreter +from bot.utils import find_nth_occurrence, send_to_paste_service + +log = logging.getLogger(__name__) + + +class Internal(Cog): + """Administrator and Core Developer commands.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.env = {} + self.ln = 0 + self.stdout = StringIO() + + self.interpreter = Interpreter(bot) + + self.socket_since = datetime.utcnow() + self.socket_event_total = 0 + self.socket_events = Counter() + + @Cog.listener() + async def on_socket_response(self, msg: dict) -> None: + """When a websocket event is received, increase our counters.""" + if event_type := msg.get("t"): + self.socket_event_total += 1 + self.socket_events[event_type] += 1 + + def _format(self, inp: str, out: Any) -> Tuple[str, Optional[discord.Embed]]: + """Format the eval output into a string & attempt to format it into an Embed.""" + self._ = out + + res = "" + + # Erase temp input we made + if inp.startswith("_ = "): + inp = inp[4:] + + # Get all non-empty lines + lines = [line for line in inp.split("\n") if line.strip()] + if len(lines) != 1: + lines += [""] + + # Create the input dialog + for i, line in enumerate(lines): + if i == 0: + # Start dialog + start = f"In [{self.ln}]: " + + else: + # Indent the 3 dots correctly; + # Normally, it's something like + # In [X]: + # ...: + # + # But if it's + # In [XX]: + # ...: + # + # You can see it doesn't look right. + # This code simply indents the dots + # far enough to align them. + # we first `str()` the line number + # then we get the length + # and use `str.rjust()` + # to indent it. + start = "...: ".rjust(len(str(self.ln)) + 7) + + if i == len(lines) - 2: + if line.startswith("return"): + line = line[6:].strip() + + # Combine everything + res += (start + line + "\n") + + self.stdout.seek(0) + text = self.stdout.read() + self.stdout.close() + self.stdout = StringIO() + + if text: + res += (text + "\n") + + if out is None: + # No output, return the input statement + return (res, None) + + res += f"Out[{self.ln}]: " + + if isinstance(out, discord.Embed): + # We made an embed? Send that as embed + res += "" + res = (res, out) + + else: + if (isinstance(out, str) and out.startswith("Traceback (most recent call last):\n")): + # Leave out the traceback message + out = "\n" + "\n".join(out.split("\n")[1:]) + + if isinstance(out, str): + pretty = out + else: + pretty = pprint.pformat(out, compact=True, width=60) + + if pretty != str(out): + # We're using the pretty version, start on the next line + res += "\n" + + if pretty.count("\n") > 20: + # Text too long, shorten + li = pretty.split("\n") + + pretty = ("\n".join(li[:3]) # First 3 lines + + "\n ...\n" # Ellipsis to indicate removed lines + + "\n".join(li[-3:])) # last 3 lines + + # Add the output + res += pretty + res = (res, None) + + return res # Return (text, embed) + + async def _eval(self, ctx: Context, code: str) -> Optional[discord.Message]: + """Eval the input code string & send an embed to the invoking context.""" + self.ln += 1 + + if code.startswith("exit"): + self.ln = 0 + self.env = {} + return await ctx.send("```Reset history!```") + + env = { + "message": ctx.message, + "author": ctx.message.author, + "channel": ctx.channel, + "guild": ctx.guild, + "ctx": ctx, + "self": self, + "bot": self.bot, + "inspect": inspect, + "discord": discord, + "contextlib": contextlib + } + + self.env.update(env) + + # Ignore this code, it works + code_ = """ +async def func(): # (None,) -> Any + try: + with contextlib.redirect_stdout(self.stdout): +{0} + if '_' in locals(): + if inspect.isawaitable(_): + _ = await _ + return _ + finally: + self.env.update(locals()) +""".format(textwrap.indent(code, ' ')) + + try: + exec(code_, self.env) # noqa: B102,S102 + func = self.env['func'] + res = await func() + + except Exception: + res = traceback.format_exc() + + out, embed = self._format(code, res) + out = out.rstrip("\n") # Strip empty lines from output + + # Truncate output to max 15 lines or 1500 characters + newline_truncate_index = find_nth_occurrence(out, "\n", 15) + + if newline_truncate_index is None or newline_truncate_index > 1500: + truncate_index = 1500 + else: + truncate_index = newline_truncate_index + + if len(out) > truncate_index: + paste_link = await send_to_paste_service(self.bot.http_session, out, extension="py") + if paste_link is not None: + paste_text = f"full contents at {paste_link}" + else: + paste_text = "failed to upload contents to paste service." + + await ctx.send( + f"```py\n{out[:truncate_index]}\n```" + f"... response truncated; {paste_text}", + embed=embed + ) + return + + await ctx.send(f"```py\n{out}```", embed=embed) + + @group(name='internal', aliases=('int',)) + @has_any_role(Roles.owners, Roles.admins, Roles.core_developers) + async def internal_group(self, ctx: Context) -> None: + """Internal commands. Top secret!""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @internal_group.command(name='eval', aliases=('e',)) + @has_any_role(Roles.admins, Roles.owners) + async def eval(self, ctx: Context, *, code: str) -> None: + """Run eval in a REPL-like format.""" + code = code.strip("`") + if re.match('py(thon)?\n', code): + code = "\n".join(code.split("\n")[1:]) + + if not re.search( # Check if it's an expression + r"^(return|import|for|while|def|class|" + r"from|exit|[a-zA-Z0-9]+\s*=)", code, re.M) and len( + code.split("\n")) == 1: + code = "_ = " + code + + await self._eval(ctx, code) + + @internal_group.command(name='socketstats', aliases=('socket', 'stats')) + @has_any_role(Roles.admins, Roles.owners, Roles.core_developers) + async def socketstats(self, ctx: Context) -> None: + """Fetch information on the socket events received from Discord.""" + running_s = (datetime.utcnow() - self.socket_since).total_seconds() + + per_s = self.socket_event_total / running_s + + stats_embed = discord.Embed( + title="WebSocket statistics", + description=f"Receiving {per_s:0.2f} event per second.", + color=discord.Color.blurple() + ) + + for event_type, count in self.socket_events.most_common(): + stats_embed.add_field(name=event_type, value=count, inline=False) + + await ctx.send(embed=stats_embed) + + +def setup(bot: Bot) -> None: + """Load the Internal cog.""" + bot.add_cog(Internal(bot)) -- cgit v1.2.3 From 58072451a02a59672dd186358e164ea580e8050f Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 3 Oct 2020 11:48:37 +0100 Subject: Cap most_common to 25 to not go over the embed fields limit --- bot/exts/utils/internal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/internal.py b/bot/exts/utils/internal.py index d61200575..1b4900f42 100644 --- a/bot/exts/utils/internal.py +++ b/bot/exts/utils/internal.py @@ -247,7 +247,7 @@ async def func(): # (None,) -> Any color=discord.Color.blurple() ) - for event_type, count in self.socket_events.most_common(): + for event_type, count in self.socket_events.most_common(25): stats_embed.add_field(name=event_type, value=count, inline=False) await ctx.send(embed=stats_embed) -- cgit v1.2.3 From 764f35fa9c54d651625aad813e2e32a0a6e6d2d6 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Sat, 3 Oct 2020 13:49:16 +0200 Subject: add missing test for `user` command --- tests/bot/exts/info/test_information.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index 23eeb88cd..4e391eb57 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -393,6 +393,11 @@ class UserEmbedTests(unittest.TestCase): embed.fields[1].value ) + self.assertEqual( + "basic infractions info", + embed.fields[2].value + ) + @unittest.mock.patch( f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) -- cgit v1.2.3 From 73a0291a4ab4b10eb9d5d4e78bc574ca25fc9c98 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 3 Oct 2020 10:06:43 -0700 Subject: Lock: rename variable to avoid shadowing --- bot/utils/lock.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/utils/lock.py b/bot/utils/lock.py index 5c9dd3725..510f41234 100644 --- a/bot/utils/lock.py +++ b/bot/utils/lock.py @@ -81,11 +81,11 @@ def lock(namespace: Hashable, resource_id: ResourceId, *, raise_error: bool = Fa # Get the lock for the ID. Create a lock if one doesn't exist yet. locks = __lock_dicts[namespace] - lock = locks.setdefault(id_, LockGuard()) + lock_guard = locks.setdefault(id_, LockGuard()) - if not lock.locked(): + if not lock_guard.locked(): log.debug(f"{name}: resource {namespace!r}:{id_!r} is free; acquiring it...") - with lock: + with lock_guard: return await func(*args, **kwargs) else: log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked") -- cgit v1.2.3 From c1c754a01b10a5c79d35c04431dd43855015ed20 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 3 Oct 2020 10:08:55 -0700 Subject: Lock: make LockGuard.locked a property --- bot/utils/lock.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/utils/lock.py b/bot/utils/lock.py index 510f41234..7aaafbc88 100644 --- a/bot/utils/lock.py +++ b/bot/utils/lock.py @@ -26,6 +26,7 @@ class LockGuard: def __init__(self): self._locked = False + @property def locked(self) -> bool: """Return True if currently locked or False if unlocked.""" return self._locked @@ -83,7 +84,7 @@ def lock(namespace: Hashable, resource_id: ResourceId, *, raise_error: bool = Fa locks = __lock_dicts[namespace] lock_guard = locks.setdefault(id_, LockGuard()) - if not lock_guard.locked(): + if not lock_guard.locked: log.debug(f"{name}: resource {namespace!r}:{id_!r} is free; acquiring it...") with lock_guard: return await func(*args, **kwargs) -- cgit v1.2.3 From a0f42eba424fe3d119f5af2632822b38b78b5bd2 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Sat, 3 Oct 2020 21:28:15 +0200 Subject: Add trailing comma to intents --- bot/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/__main__.py b/bot/__main__.py index 133c96302..da042a5ed 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -62,7 +62,7 @@ bot = Bot( case_insensitive=True, max_messages=10_000, allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles), - intents=intents + intents=intents, ) # Load extensions. -- cgit v1.2.3 From 397d29a2f51f2a02558998efe8777a9efa575a43 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Sat, 3 Oct 2020 21:28:57 +0200 Subject: Use invite for tracking offline presences instead of `ctx` --- bot/exts/info/information.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index ca9895d61..0f50138e7 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -152,9 +152,9 @@ class Information(Cog): channel_counts = self.get_channel_type_counts(ctx.guild) # How many of each user status? - py_invite = await self.bot.fetch_invite("python") + py_invite = await self.bot.fetch_invite(constants.Guild.invite) online_presences = py_invite.approximate_presence_count - offline_presences = ctx.guild.member_count - online_presences + offline_presences = py_invite.approximate_member_count - online_presences embed = Embed(colour=Colour.blurple()) # How many staff members and staff channels do we have? -- cgit v1.2.3 From 9b63db31fc9b6fe6a726f711383cb38b2c44bd40 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Sat, 3 Oct 2020 20:38:46 -0400 Subject: Replace `map` with a more pythonic list comprehension. --- bot/exts/info/site.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py index 2d3a3d9f3..9e7f6b0a5 100644 --- a/bot/exts/info/site.py +++ b/bot/exts/info/site.py @@ -129,7 +129,7 @@ class Site(Cog): ) if invalid_indices: - indices = ', '.join(map(str, invalid_indices)) + indices = ', '.join(str(index) for index in invalid_indices) await ctx.send(f":x: Invalid rule indices: {indices}") return -- cgit v1.2.3 From bfcd4689b8cf8f0d1a26ffc1e1b0b4b9b9e9b59d Mon Sep 17 00:00:00 2001 From: Den4200 Date: Sat, 3 Oct 2020 20:40:37 -0400 Subject: Remove duplicates from given rule indices and sort them in order. --- bot/exts/info/site.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py index 9e7f6b0a5..c8ae8dc96 100644 --- a/bot/exts/info/site.py +++ b/bot/exts/info/site.py @@ -122,10 +122,14 @@ class Site(Cog): return full_rules = await self.bot.api_client.get('rules', params={'link_format': 'md'}) - invalid_indices = tuple( - pick - for pick in rules - if pick < 1 or pick > len(full_rules) + + # Remove duplicates and sort the invalid rule indices + invalid_indices = sorted( + set( + pick + for pick in rules + if pick < 1 or pick > len(full_rules) + ) ) if invalid_indices: @@ -136,6 +140,9 @@ class Site(Cog): for rule in rules: self.bot.stats.incr(f"rule_uses.{rule}") + # Remove duplicates and sort the rule indices + rules = sorted(set(rules)) + final_rules = tuple(f"**{pick}.** {full_rules[pick - 1]}" for pick in rules) await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3) -- cgit v1.2.3 From 6cc110cf93dd109e371dfae7ad93520920883ca8 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Sat, 3 Oct 2020 20:41:17 -0400 Subject: Use `Greedy` converter instead of the splat operator. --- bot/exts/info/site.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py index c8ae8dc96..12c1737a2 100644 --- a/bot/exts/info/site.py +++ b/bot/exts/info/site.py @@ -1,7 +1,7 @@ import logging from discord import Colour, Embed -from discord.ext.commands import Cog, Context, group +from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot from bot.constants import URLs @@ -105,7 +105,7 @@ class Site(Cog): await ctx.send(embed=embed) @site_group.command(name="rules", aliases=("r", "rule"), root_aliases=("rules", "rule")) - async def site_rules(self, ctx: Context, *rules: int) -> None: + async def site_rules(self, ctx: Context, rules: Greedy[int]) -> None: """Provides a link to all rules or, if specified, displays specific rule(s).""" rules_embed = Embed(title='Rules', color=Colour.blurple()) rules_embed.url = f"{PAGES_URL}/rules" -- cgit v1.2.3 From dcad46fd4637fadc16f69da6bb92dd3513f68d76 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Sat, 3 Oct 2020 21:10:10 -0400 Subject: Use `url` argument instead of setting it outside. --- bot/exts/info/site.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py index 12c1737a2..bf2547895 100644 --- a/bot/exts/info/site.py +++ b/bot/exts/info/site.py @@ -107,8 +107,7 @@ class Site(Cog): @site_group.command(name="rules", aliases=("r", "rule"), root_aliases=("rules", "rule")) async def site_rules(self, ctx: Context, rules: Greedy[int]) -> None: """Provides a link to all rules or, if specified, displays specific rule(s).""" - rules_embed = Embed(title='Rules', color=Colour.blurple()) - rules_embed.url = f"{PAGES_URL}/rules" + rules_embed = Embed(title='Rules', color=Colour.blurple(), url=f'{PAGES_URL}/rules') if not rules: # Rules were not submitted. Return the default description. -- cgit v1.2.3 From 20c5a6946a140ef9e79f8a7c4edb60e2d5372298 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 4 Oct 2020 16:59:22 +0300 Subject: Added interleaving text in code blocks option If the message contains both plaintext and code blocks, the text will be ignored. If several code blocks are present, they are concatenated. --- bot/exts/utils/snekbox.py | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index ca6fbf5cb..e1839bdf7 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -31,6 +31,15 @@ FORMATTED_CODE_REGEX = re.compile( r"\s*$", # any trailing whitespace until the end of the string re.DOTALL | re.IGNORECASE # "." also matches newlines, case insensitive ) +CODE_BLOCK_REGEX = re.compile( + r"```" # code block delimiter: 3 batckticks + r"([a-z]+\n)?" # match optional language (only letters plus newline) + r"(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code + r"(?P.*?)" # extract all code inside the markup + r"\s*" # any more whitespace before the end of the code markup + r"```", # code block end + re.DOTALL | re.IGNORECASE # "." also matches newlines, case insensitive +) RAW_CODE_REGEX = re.compile( r"^(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code r"(?P.*?)" # extract all the rest as code @@ -78,7 +87,9 @@ class Snekbox(Cog): def prepare_input(code: str) -> str: """Extract code from the Markdown, format it, and insert it into the code template.""" match = FORMATTED_CODE_REGEX.fullmatch(code) - if match: + + # Despite the wildcard being lazy, this is a fullmatch so we need to check the presence of the delim explicitly. + if match and match.group("delim") not in match.group("code"): code, block, lang, delim = match.group("code", "block", "lang", "delim") code = textwrap.dedent(code) if block: @@ -86,12 +97,20 @@ class Snekbox(Cog): else: info = f"{delim}-enclosed inline code" log.trace(f"Extracted {info} for evaluation:\n{code}") + else: - code = textwrap.dedent(RAW_CODE_REGEX.fullmatch(code).group("code")) - log.trace( - f"Eval message contains unformatted or badly formatted code, " - f"stripping whitespace only:\n{code}" - ) + code_parts = CODE_BLOCK_REGEX.finditer(code) + merge = '\n'.join(map(lambda part: part.group("code"), code_parts)) + if merge: + code = textwrap.dedent(merge) + log.trace(f"Merged one or more code blocks from text combined with code:\n{code}") + + else: + code = textwrap.dedent(RAW_CODE_REGEX.fullmatch(code).group("code")) + log.trace( + f"Eval message contains unformatted or badly formatted code, " + f"stripping whitespace only:\n{code}" + ) return code -- cgit v1.2.3 From 2553a1d35bf52681dc8b28327e15fbd3ec14910e Mon Sep 17 00:00:00 2001 From: Den4200 Date: Sun, 4 Oct 2020 11:20:08 -0400 Subject: Sort rules before determining invalid indices. This is to avoid sorting twice - once for invalid indices and again for send the rules. --- bot/exts/info/site.py | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py index bf2547895..fb5b99086 100644 --- a/bot/exts/info/site.py +++ b/bot/exts/info/site.py @@ -122,26 +122,17 @@ class Site(Cog): full_rules = await self.bot.api_client.get('rules', params={'link_format': 'md'}) - # Remove duplicates and sort the invalid rule indices - invalid_indices = sorted( - set( - pick - for pick in rules - if pick < 1 or pick > len(full_rules) - ) - ) + # Remove duplicates and sort the rule indices + rules = sorted(set(rules)) + invalid = ', '.join(str(index) for index in rules if index < 1 or index > len(full_rules)) - if invalid_indices: - indices = ', '.join(str(index) for index in invalid_indices) - await ctx.send(f":x: Invalid rule indices: {indices}") + if invalid: + await ctx.send(f":x: Invalid rule indices: {invalid}") return for rule in rules: self.bot.stats.incr(f"rule_uses.{rule}") - # Remove duplicates and sort the rule indices - rules = sorted(set(rules)) - final_rules = tuple(f"**{pick}.** {full_rules[pick - 1]}" for pick in rules) await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3) -- cgit v1.2.3 From 08140e8ceab3ab46a1c956b7a4c90b771064d3c6 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 4 Oct 2020 18:34:50 +0300 Subject: Improved style and fixed comment. --- bot/exts/utils/snekbox.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index e1839bdf7..e782ed745 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -88,7 +88,7 @@ class Snekbox(Cog): """Extract code from the Markdown, format it, and insert it into the code template.""" match = FORMATTED_CODE_REGEX.fullmatch(code) - # Despite the wildcard being lazy, this is a fullmatch so we need to check the presence of the delim explicitly. + # Despite the wildcard being lazy, the pattern is from start to end and will eat any delimiters in the middle. if match and match.group("delim") not in match.group("code"): code, block, lang, delim = match.group("code", "block", "lang", "delim") code = textwrap.dedent(code) @@ -100,7 +100,7 @@ class Snekbox(Cog): else: code_parts = CODE_BLOCK_REGEX.finditer(code) - merge = '\n'.join(map(lambda part: part.group("code"), code_parts)) + merge = '\n'.join(part.group("code") for part in code_parts) if merge: code = textwrap.dedent(merge) log.trace(f"Merged one or more code blocks from text combined with code:\n{code}") -- cgit v1.2.3 From 233f63551bf1945d83f0418e506f9ec9a9381ac6 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 6 Oct 2020 00:15:20 +0100 Subject: Support users with alternate gating methods --- bot/exts/moderation/verification.py | 42 +++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index 206556483..1d1dacb37 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -53,6 +53,23 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! <#{constants.Channels.bot_commands}>. """ +ALTERNATE_VERIFIED_MESSAGE = f""" +Thanks for accepting our rules! + +You can find a copy of our rules for reference at . + +Additionally, if you'd like to receive notifications for the announcements \ +we post in <#{constants.Channels.announcements}> +from time to time, you can send `!subscribe` to <#{constants.Channels.bot_commands}> at any time \ +to assign yourself the **Announcements** role. We'll mention this role every time we make an announcement. + +If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to \ +<#{constants.Channels.bot_commands}>. + +To introduce you to our community, we've made the following video: +https://youtu.be/ZH26PuX3re0 +""" + # Sent via DMs to users kicked for failing to verify KICKED_MESSAGE = f""" Hi! You have been automatically kicked from Python Discord as you have failed to accept our rules \ @@ -156,6 +173,9 @@ class Verification(Cog): # ] task_cache = RedisCache() + # Cache who needs to receive an alternate verified DM. + member_gating_cache = RedisCache() + def __init__(self, bot: Bot) -> None: """Start internal tasks.""" self.bot = bot @@ -519,12 +539,34 @@ class Verification(Cog): if member.guild.id != constants.Guild.id: return # Only listen for PyDis events + raw_member = await self.bot.http.get_member(member.guild.id, member.id) + + # Only send the message to users going through our gating system + if raw_member["is_pending"]: + await self.member_gating_cache.set(raw_member.id, True) + return + log.trace(f"Sending on join message to new member: {member.id}") try: await safe_dm(member.send(ON_JOIN_MESSAGE)) except discord.HTTPException: log.exception("DM dispatch failed on unexpected error code") + @Cog.listener() + async def on_member_update(self, before: discord.Member, after: discord.Member): + """Check if we need to send a verification DM to a gated user.""" + before_roles = [r.id for r in before.roles] + after_roles = [r.id for r in after.roles] + + if constants.Roles.verified not in before_roles and constants.Roles.verified in after_roles: + if await self.member_gating_cache.get(after.id): + try: + await safe_dm(after.send(ALTERNATE_VERIFIED_MESSAGE)) + except discord.HTTPException: + log.exception("DM dispatch failed on unexpected error code") + finally: + self.member_gating_cache.pop(after.id) + @Cog.listener() async def on_message(self, message: discord.Message) -> None: """Check new message event for messages to the checkpoint channel & process.""" -- cgit v1.2.3 From 2f18813a08c544f5d8973ba0f3a7d0e78a3dc6eb Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 6 Oct 2020 00:23:29 +0100 Subject: Add type annotation to on_member_update listener --- bot/exts/moderation/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index 1d1dacb37..b86a67225 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -553,7 +553,7 @@ class Verification(Cog): log.exception("DM dispatch failed on unexpected error code") @Cog.listener() - async def on_member_update(self, before: discord.Member, after: discord.Member): + async def on_member_update(self, before: discord.Member, after: discord.Member) -> None: """Check if we need to send a verification DM to a gated user.""" before_roles = [r.id for r in before.roles] after_roles = [r.id for r in after.roles] -- cgit v1.2.3 From 880b936faf83d8fa3d7489e1f9eaab89b93af1b8 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 6 Oct 2020 00:36:13 +0100 Subject: Merge get and pop into one conditional --- bot/exts/moderation/verification.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index b86a67225..d5eb61f13 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -559,13 +559,11 @@ class Verification(Cog): after_roles = [r.id for r in after.roles] if constants.Roles.verified not in before_roles and constants.Roles.verified in after_roles: - if await self.member_gating_cache.get(after.id): + if await self.member_gating_cache.pop(after.id): try: await safe_dm(after.send(ALTERNATE_VERIFIED_MESSAGE)) except discord.HTTPException: log.exception("DM dispatch failed on unexpected error code") - finally: - self.member_gating_cache.pop(after.id) @Cog.listener() async def on_message(self, message: discord.Message) -> None: -- cgit v1.2.3 From 406da780c06a6797b860d816c4a418def9a3f116 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 6 Oct 2020 00:39:54 +0100 Subject: Use clearer variable names in list comprehensions --- bot/exts/moderation/verification.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index d5eb61f13..8ad42a035 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -555,8 +555,8 @@ class Verification(Cog): @Cog.listener() async def on_member_update(self, before: discord.Member, after: discord.Member) -> None: """Check if we need to send a verification DM to a gated user.""" - before_roles = [r.id for r in before.roles] - after_roles = [r.id for r in after.roles] + before_roles = [role.id for role in before.roles] + after_roles = [role.id for role in after.roles] if constants.Roles.verified not in before_roles and constants.Roles.verified in after_roles: if await self.member_gating_cache.pop(after.id): -- cgit v1.2.3 From ceffe46a0d5136308c8f0684c2c406dd34e758fb Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 6 Oct 2020 00:41:16 +0100 Subject: Reword cache creation comment --- bot/exts/moderation/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index 8ad42a035..c675b8db9 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -173,7 +173,7 @@ class Verification(Cog): # ] task_cache = RedisCache() - # Cache who needs to receive an alternate verified DM. + # Create a cache for storing recipients of the alternate welcome DM. member_gating_cache = RedisCache() def __init__(self, bot: Bot) -> None: -- cgit v1.2.3 From 8d6d3ef56d29c2eff372c858fa0a228eeefdbfb8 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 6 Oct 2020 00:43:50 +0100 Subject: Clear up comment around DM send --- bot/exts/moderation/verification.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index c675b8db9..89e1cdd7e 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -561,6 +561,10 @@ class Verification(Cog): if constants.Roles.verified not in before_roles and constants.Roles.verified in after_roles: if await self.member_gating_cache.pop(after.id): try: + # If the member has not received a DM from our !accept command + # and has gone through the alternate gating system we should send + # our alternate welcome DM which includes info such as our welcome + # video. await safe_dm(after.send(ALTERNATE_VERIFIED_MESSAGE)) except discord.HTTPException: log.exception("DM dispatch failed on unexpected error code") -- cgit v1.2.3 From ea3217effacc02e06444ea0b21985cd7439a13e7 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 6 Oct 2020 00:45:31 +0100 Subject: Reword on_join comment for alternate gate members --- bot/exts/moderation/verification.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index 89e1cdd7e..659c7414f 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -541,7 +541,10 @@ class Verification(Cog): raw_member = await self.bot.http.get_member(member.guild.id, member.id) - # Only send the message to users going through our gating system + # If the user has the is_pending flag set, they will be using the alternate + # gate and will not need a welcome DM with verification instructions. + # We will send them an alternate DM once they verify with the welcome + # video. if raw_member["is_pending"]: await self.member_gating_cache.set(raw_member.id, True) return -- cgit v1.2.3 From 082e26342eb3faf104523334cdb87e07eda03db3 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 6 Oct 2020 00:46:09 +0100 Subject: Correct raw_member to member in verification on_join --- bot/exts/moderation/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index 659c7414f..3b5d7e58b 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -546,7 +546,7 @@ class Verification(Cog): # We will send them an alternate DM once they verify with the welcome # video. if raw_member["is_pending"]: - await self.member_gating_cache.set(raw_member.id, True) + await self.member_gating_cache.set(member.id, True) return log.trace(f"Sending on join message to new member: {member.id}") -- cgit v1.2.3 From 42697e85354223fc1c678bfaf7e273274a9c81bc Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 6 Oct 2020 00:50:38 +0100 Subject: Use .get() instead of index for fetching is_pending property --- bot/exts/moderation/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index 3b5d7e58b..c3ad8687e 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -545,7 +545,7 @@ class Verification(Cog): # gate and will not need a welcome DM with verification instructions. # We will send them an alternate DM once they verify with the welcome # video. - if raw_member["is_pending"]: + if raw_member.get("is_pending"): await self.member_gating_cache.set(member.id, True) return -- cgit v1.2.3 From ace40ecf463a17ad228541ac9ed9a97df15c624c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 5 Oct 2020 19:03:59 -0700 Subject: Code block: support the "pycon" language specifier It's used for code copied from the Python REPL. --- bot/cogs/codeblock/parsing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 01c220c61..e67224494 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -12,7 +12,7 @@ from bot.utils import has_lines log = logging.getLogger(__name__) BACKTICK = "`" -PY_LANG_CODES = ("python", "py") # Order is important; "py" is second cause it's a subset. +PY_LANG_CODES = ("python", "pycon", "py") # Order is important; "py" is last cause it's a subset. _TICKS = { BACKTICK, "'", -- cgit v1.2.3 From d69804ee73391dc7c95d1d743615ba4b7a1de7d8 Mon Sep 17 00:00:00 2001 From: Boris Muratov Date: Tue, 6 Oct 2020 11:43:10 +0300 Subject: Fix old nick in superstarify reason --- 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 eec63f5b3..adfe42fcd 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -135,7 +135,8 @@ class Superstarify(InfractionScheduler, Cog): return # Post the infraction to the API - reason = reason or f"old nick: {member.display_name}" + old_nick = member.display_name + reason = reason or f"old nick: {old_nick}" infraction = await _utils.post_infraction(ctx, member, "superstar", reason, duration, active=True) id_ = infraction["id"] @@ -148,7 +149,7 @@ class Superstarify(InfractionScheduler, Cog): await member.edit(nick=forced_nick, reason=reason) self.schedule_expiration(infraction) - old_nick = escape_markdown(member.display_name) + old_nick = escape_markdown(old_nick) forced_nick = escape_markdown(forced_nick) # Send a DM to the user to notify them of their new infraction. -- cgit v1.2.3 From 672704841ddfe79d393a621e8c934bdb362f4ef0 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 6 Oct 2020 21:36:34 +0200 Subject: Include rolled over logs in gitignore RotatingFileHandler appends .# to log names when rolling over to a new file. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index fb3156ab1..2074887ad 100644 --- a/.gitignore +++ b/.gitignore @@ -110,6 +110,7 @@ ENV/ # Logfiles log.* +*.log.* # Custom user configuration config.yml -- cgit v1.2.3 From 5381552c18b541121a33171f4763047e03362780 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 6 Oct 2020 12:45:07 -0700 Subject: Silence: move unsilence scheduling to a separate function --- bot/cogs/moderation/silence.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 8e15b2284..08d0328ab 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -110,16 +110,12 @@ class Silence(commands.Cog): await ctx.send(MSG_SILENCE_FAIL) return + await self._schedule_unsilence(ctx, duration) + if duration is None: await ctx.send(MSG_SILENCE_PERMANENT) - await self.unsilence_timestamps.set(ctx.channel.id, -1) - return - - await ctx.send(MSG_SILENCE_SUCCESS.format(duration=duration)) - - self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence)) - unsilence_time = (datetime.now(tz=timezone.utc) + timedelta(minutes=duration)) - await self.unsilence_timestamps.set(ctx.channel.id, unsilence_time.timestamp()) + else: + await ctx.send(MSG_SILENCE_SUCCESS.format(duration=duration)) @commands.command(aliases=("unhush",)) async def unsilence(self, ctx: Context) -> None: @@ -170,6 +166,15 @@ class Silence(commands.Cog): log.info(f"Silenced #{channel} ({channel.id}) for {duration} minute(s).") return True + async def _schedule_unsilence(self, ctx: Context, duration: Optional[int]) -> None: + """Schedule `ctx.channel` to be unsilenced if `duration` is not None.""" + if duration is None: + await self.unsilence_timestamps.set(ctx.channel.id, -1) + else: + self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence)) + unsilence_time = (datetime.now(tz=timezone.utc) + timedelta(minutes=duration)) + await self.unsilence_timestamps.set(ctx.channel.id, unsilence_time.timestamp()) + async def _unsilence(self, channel: TextChannel) -> bool: """ Unsilence `channel`. -- cgit v1.2.3 From cf2c03215ef340b9e093828de365563bb6be587a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 6 Oct 2020 13:23:03 -0700 Subject: Silence: refactor _silence * Rename to `_silence_overwrites` * Reduce responsibilities to only setting permission overwrites * Log in `silence` instead * Add to notifier in `silence` instead --- bot/cogs/moderation/silence.py | 27 ++++++++-------------- tests/bot/cogs/moderation/test_silence.py | 38 ++++++++++++++++++------------- 2 files changed, 32 insertions(+), 33 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 08d0328ab..12896022f 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -104,17 +104,23 @@ class Silence(commands.Cog): Indefinitely silenced channels get added to a notifier which posts notices every 15 minutes from the start. """ await self._init_task - log.debug(f"{ctx.author} is silencing channel #{ctx.channel}.") - if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration): + channel_info = f"#{ctx.channel} ({ctx.channel.id})" + log.debug(f"{ctx.author} is silencing channel {channel_info}.") + + if not await self._silence_overwrites(ctx.channel): + log.info(f"Tried to silence channel {channel_info} but the channel was already silenced.") await ctx.send(MSG_SILENCE_FAIL) return await self._schedule_unsilence(ctx, duration) if duration is None: + log.info(f"Silenced {channel_info} indefinitely.") await ctx.send(MSG_SILENCE_PERMANENT) else: + self.notifier.add_channel(ctx.channel) + log.info(f"Silenced {channel_info} for {duration} minute(s).") await ctx.send(MSG_SILENCE_SUCCESS.format(duration=duration)) @commands.command(aliases=("unhush",)) @@ -139,31 +145,18 @@ class Silence(commands.Cog): else: await channel.send(MSG_UNSILENCE_SUCCESS) - async def _silence(self, channel: TextChannel, persistent: bool, duration: Optional[int]) -> bool: - """ - Silence `channel` for `self._verified_role`. - - If `persistent` is `True` add `channel` to notifier. - `duration` is only used for logging; if None is passed `persistent` should be True to not log None. - Return `True` if channel permissions were changed, `False` otherwise. - """ + async def _silence_overwrites(self, channel: TextChannel) -> bool: + """Set silence permission overwrites for `channel` and return True if successful.""" overwrite = channel.overwrites_for(self._verified_role) prev_overwrites = dict(send_messages=overwrite.send_messages, add_reactions=overwrite.add_reactions) if channel.id in self.scheduler or all(val is False for val in prev_overwrites.values()): - log.info(f"Tried to silence channel #{channel} ({channel.id}) but the channel was already silenced.") return False overwrite.update(send_messages=False, add_reactions=False) await channel.set_permissions(self._verified_role, overwrite=overwrite) await self.previous_overwrites.set(channel.id, json.dumps(prev_overwrites)) - if persistent: - log.info(f"Silenced #{channel} ({channel.id}) indefinitely.") - self.notifier.add_channel(channel) - return True - - log.info(f"Silenced #{channel} ({channel.id}) for {duration} minute(s).") return True async def _schedule_unsilence(self, ctx: Context, duration: Optional[int]) -> None: diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index d56a731b6..9dbdfd10a 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -245,8 +245,8 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): ) for duration, message, was_silenced in test_cases: ctx = MockContext() - with self.subTest(was_silenced=was_silenced, message=message, duration=duration): - with mock.patch.object(self.cog, "_silence", return_value=was_silenced): + with mock.patch.object(self.cog, "_silence_overwrites", return_value=was_silenced): + with self.subTest(was_silenced=was_silenced, message=message, duration=duration): await self.cog.silence.callback(self.cog, ctx, duration) ctx.send.assert_called_once_with(message) @@ -264,12 +264,12 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): channel = MockTextChannel() channel.overwrites_for.return_value = overwrite - self.assertFalse(await self.cog._silence(channel, True, None)) + self.assertFalse(await self.cog._silence_overwrites(channel)) channel.set_permissions.assert_not_called() async def test_silenced_channel(self): """Channel had `send_message` and `add_reactions` permissions revoked for verified role.""" - self.assertTrue(await self.cog._silence(self.channel, False, None)) + self.assertTrue(await self.cog._silence_overwrites(self.channel)) self.assertFalse(self.overwrite.send_messages) self.assertFalse(self.overwrite.add_reactions) self.channel.set_permissions.assert_awaited_once_with( @@ -280,7 +280,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_preserved_other_overwrites(self): """Channel's other unrelated overwrites were not changed.""" prev_overwrite_dict = dict(self.overwrite) - await self.cog._silence(self.channel, False, None) + await self.cog._silence_overwrites(self.channel) new_overwrite_dict = dict(self.overwrite) # Remove 'send_messages' & 'add_reactions' keys because they were changed by the method. @@ -291,22 +291,28 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) - async def test_added_removed_notifier(self): - """Channel was added to notifier if `persistent` was `True`, and removed if `False`.""" - with mock.patch.object(self.cog, "notifier", create=True): - with self.subTest(persistent=True): - await self.cog._silence(self.channel, True, None) - self.cog.notifier.add_channel.assert_called_once() + async def test_temp_added_to_notifier(self): + """Channel was added to notifier if a duration was set for the silence.""" + with mock.patch.object(self.cog, "_silence_overwrites", return_value=True): + await self.cog.silence.callback(self.cog, MockContext(), 15) + self.cog.notifier.add_channel.assert_called_once() - with mock.patch.object(self.cog, "notifier", create=True): - with self.subTest(persistent=False): - await self.cog._silence(self.channel, False, None) - self.cog.notifier.add_channel.assert_not_called() + async def test_indefinite_not_added_to_notifier(self): + """Channel was not added to notifier if a duration was not set for the silence.""" + with mock.patch.object(self.cog, "_silence_overwrites", return_value=True): + await self.cog.silence.callback(self.cog, MockContext(), None) + self.cog.notifier.add_channel.assert_not_called() + + async def test_silenced_not_added_to_notifier(self): + """Channel was not added to the notifier if it was already silenced.""" + with mock.patch.object(self.cog, "_silence_overwrites", return_value=False): + await self.cog.silence.callback(self.cog, MockContext(), 15) + self.cog.notifier.add_channel.assert_not_called() async def test_cached_previous_overwrites(self): """Channel's previous overwrites were cached.""" overwrite_json = '{"send_messages": true, "add_reactions": false}' - await self.cog._silence(self.channel, False, None) + await self.cog._silence_overwrites(self.channel) self.cog.previous_overwrites.set.assert_called_once_with(self.channel.id, overwrite_json) @autospec(silence, "datetime") -- cgit v1.2.3 From f218c7b0a505416d44b177b0e863575db626d20c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 6 Oct 2020 13:27:42 -0700 Subject: Silence: rename _init_cog to _async_init --- bot/cogs/moderation/silence.py | 4 ++-- tests/bot/cogs/moderation/test_silence.py | 26 +++++++++++++------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 12896022f..178dee06f 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -82,9 +82,9 @@ class Silence(commands.Cog): self.bot = bot self.scheduler = Scheduler(self.__class__.__name__) - self._init_task = self.bot.loop.create_task(self._init_cog()) + self._init_task = self.bot.loop.create_task(self._async_init()) - async def _init_cog(self) -> None: + async def _async_init(self) -> None: """Set instance attributes once the guild is available and reschedule unsilences.""" await self.bot.wait_until_guild_available() diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 9dbdfd10a..5588115ae 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -91,39 +91,39 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): self.cog = silence.Silence(self.bot) @autospec(silence, "SilenceNotifier", pass_mocks=False) - async def test_init_cog_got_guild(self): + async def test_async_init_got_guild(self): """Bot got guild after it became available.""" - await self.cog._init_cog() + await self.cog._async_init() self.bot.wait_until_guild_available.assert_awaited_once() self.bot.get_guild.assert_called_once_with(Guild.id) @autospec(silence, "SilenceNotifier", pass_mocks=False) - async def test_init_cog_got_role(self): + async def test_async_init_got_role(self): """Got `Roles.verified` role from guild.""" - await self.cog._init_cog() + await self.cog._async_init() guild = self.bot.get_guild() guild.get_role.assert_called_once_with(Roles.verified) @autospec(silence, "SilenceNotifier", pass_mocks=False) - async def test_init_cog_got_channels(self): + async def test_async_init_got_channels(self): """Got channels from bot.""" - await self.cog._init_cog() + await self.cog._async_init() self.bot.get_channel.called_once_with(Channels.mod_alerts) self.bot.get_channel.called_once_with(Channels.mod_log) @autospec(silence, "SilenceNotifier") - async def test_init_cog_got_notifier(self, notifier): + async def test_async_init_got_notifier(self, notifier): """Notifier was started with channel.""" mod_log = MockTextChannel() self.bot.get_channel.side_effect = (None, mod_log) - await self.cog._init_cog() + await self.cog._async_init() notifier.assert_called_once_with(self.cog._mod_log_channel) @autospec(silence, "SilenceNotifier", pass_mocks=False) - async def test_init_cog_rescheduled(self): + async def test_async_init_rescheduled(self): """`_reschedule_` coroutine was awaited.""" self.cog._reschedule = mock.create_autospec(self.cog._reschedule) - await self.cog._init_cog() + await self.cog._async_init() self.cog._reschedule.assert_awaited_once_with() def test_cog_unload_cancelled_tasks(self): @@ -154,7 +154,7 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): self.cog._unsilence_wrapper = mock.create_autospec(self.cog._unsilence_wrapper) with mock.patch.object(self.cog, "_reschedule", autospec=True): - asyncio.run(self.cog._init_cog()) # Populate instance attributes. + asyncio.run(self.cog._async_init()) # Populate instance attributes. async def test_skipped_missing_channel(self): """Did nothing because the channel couldn't be retrieved.""" @@ -230,7 +230,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.cog._init_task = asyncio.Future() self.cog._init_task.set_result(None) - asyncio.run(self.cog._init_cog()) # Populate instance attributes. + asyncio.run(self.cog._async_init()) # Populate instance attributes. self.channel = MockTextChannel() self.overwrite = PermissionOverwrite(stream=True, send_messages=True, add_reactions=False) @@ -367,7 +367,7 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): overwrites_cache = mock.create_autospec(self.cog.previous_overwrites, spec_set=True) self.cog.previous_overwrites = overwrites_cache - asyncio.run(self.cog._init_cog()) # Populate instance attributes. + asyncio.run(self.cog._async_init()) # Populate instance attributes. self.cog.scheduler.__contains__.return_value = True overwrites_cache.get.return_value = '{"send_messages": true, "add_reactions": false}' -- cgit v1.2.3 From 507451b8e67eb0a8425fa1dd2b5d386ead18ce00 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Wed, 7 Oct 2020 01:44:01 +0300 Subject: prepare_input uses one regex less --- bot/exts/utils/snekbox.py | 52 ++++++++++++++++------------------------------- 1 file changed, 17 insertions(+), 35 deletions(-) diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index e782ed745..77830209e 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -21,23 +21,12 @@ log = logging.getLogger(__name__) ESCAPE_REGEX = re.compile("[`\u202E\u200B]{3,}") FORMATTED_CODE_REGEX = re.compile( - r"^\s*" # any leading whitespace from the beginning of the string r"(?P(?P```)|``?)" # code delimiter: 1-3 backticks; (?P=block) only matches if it's a block r"(?(block)(?:(?P[a-z]+)\n)?)" # if we're in a block, match optional language (only letters plus newline) r"(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code r"(?P.*?)" # extract all code inside the markup r"\s*" # any more whitespace before the end of the code markup - r"(?P=delim)" # match the exact same delimiter from the start again - r"\s*$", # any trailing whitespace until the end of the string - re.DOTALL | re.IGNORECASE # "." also matches newlines, case insensitive -) -CODE_BLOCK_REGEX = re.compile( - r"```" # code block delimiter: 3 batckticks - r"([a-z]+\n)?" # match optional language (only letters plus newline) - r"(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code - r"(?P.*?)" # extract all code inside the markup - r"\s*" # any more whitespace before the end of the code markup - r"```", # code block end + r"(?P=delim)", # match the exact same delimiter from the start again re.DOTALL | re.IGNORECASE # "." also matches newlines, case insensitive ) RAW_CODE_REGEX = re.compile( @@ -86,32 +75,25 @@ class Snekbox(Cog): @staticmethod def prepare_input(code: str) -> str: """Extract code from the Markdown, format it, and insert it into the code template.""" - match = FORMATTED_CODE_REGEX.fullmatch(code) - - # Despite the wildcard being lazy, the pattern is from start to end and will eat any delimiters in the middle. - if match and match.group("delim") not in match.group("code"): - code, block, lang, delim = match.group("code", "block", "lang", "delim") - code = textwrap.dedent(code) - if block: - info = (f"'{lang}' highlighted" if lang else "plain") + " code block" - else: - info = f"{delim}-enclosed inline code" - log.trace(f"Extracted {info} for evaluation:\n{code}") - - else: - code_parts = CODE_BLOCK_REGEX.finditer(code) - merge = '\n'.join(part.group("code") for part in code_parts) - if merge: - code = textwrap.dedent(merge) - log.trace(f"Merged one or more code blocks from text combined with code:\n{code}") + if match := list(FORMATTED_CODE_REGEX.finditer(code)): + blocks = [block for block in match if block.group("block")] + if len(blocks) > 1: + code = '\n'.join(block.group("code") for block in blocks) + info = "several code blocks" else: - code = textwrap.dedent(RAW_CODE_REGEX.fullmatch(code).group("code")) - log.trace( - f"Eval message contains unformatted or badly formatted code, " - f"stripping whitespace only:\n{code}" - ) + match = match[0] if len(blocks) == 0 else blocks[0] + code, block, lang, delim = match.group("code", "block", "lang", "delim") + if block: + info = (f"'{lang}' highlighted" if lang else "plain") + " code block" + else: + info = f"{delim}-enclosed inline code" + else: + code = RAW_CODE_REGEX.fullmatch(code).group("code") + info = "unformatted or badly formatted code" + code = textwrap.dedent(code) + log.trace(f"Extracted {info} for evaluation:\n{code}") return code @staticmethod -- cgit v1.2.3 From 196838d54f8b80f58807eaefe5914467b5581df1 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Wed, 7 Oct 2020 17:15:01 +1000 Subject: Add the ability to purge and ban in one command. --- bot/exts/moderation/infraction/infractions.py | 28 +++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index a8b3feb38..9d6de1a97 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -71,6 +71,23 @@ class Infractions(InfractionScheduler, commands.Cog): """Permanently ban a user for the given reason and stop watching them with Big Brother.""" await self.apply_ban(ctx, user, reason) + @command() + async def purgeban( + self, + ctx: Context, + user: FetchedMember, + purge_days: t.Optional[int] = 1, + *, + reason: t.Optional[str] = None + ) -> None: + """ + Same as ban but removes all their messages for the given number of days, default being 1. + + `purge_days` can only be values between 0 and 7. + Anything outside these bounds are automatically adjusted to their respective limits. + """ + await self.apply_ban(ctx, user, reason, max(min(purge_days, 7), 0)) + # endregion # region: Temporary infractions @@ -246,7 +263,14 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_infraction(ctx, infraction, user, action) @respect_role_hierarchy(member_arg=2) - async def apply_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None: + async def apply_ban( + self, + ctx: Context, + user: UserSnowflake, + reason: t.Optional[str], + purge_days: t.Optional[int] = 0, + **kwargs + ) -> None: """ Apply a ban infraction with kwargs passed to `post_infraction`. @@ -278,7 +302,7 @@ class Infractions(InfractionScheduler, commands.Cog): if reason: reason = textwrap.shorten(reason, width=512, placeholder="...") - action = ctx.guild.ban(user, reason=reason, delete_message_days=0) + action = ctx.guild.ban(user, reason=reason, delete_message_days=purge_days) await self.apply_infraction(ctx, infraction, user, action) if infraction.get('expires_at') is not None: -- cgit v1.2.3 From 7e7a801366e2bf8f1190fae91f93729b33f32895 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Wed, 7 Oct 2020 23:02:39 +0530 Subject: improve code efficiency and use updated API changes to pagination --- bot/exts/backend/sync/_syncers.py | 146 +++++++++++++------------------------- 1 file changed, 48 insertions(+), 98 deletions(-) diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index ae7d5d893..70887a217 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -2,7 +2,6 @@ import abc import logging import typing as t from collections import namedtuple -from urllib.parse import parse_qsl, urlparse from discord import Guild from discord.ext.commands import Context @@ -15,7 +14,6 @@ log = logging.getLogger(__name__) # These objects are declared as namedtuples because tuples are hashable, # something that we make use of when diffing site roles against guild roles. _Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions', 'position')) -_User = namedtuple('User', ('id', 'name', 'discriminator', 'roles', 'in_guild')) _Diff = namedtuple('Diff', ('created', 'updated', 'deleted')) @@ -42,11 +40,7 @@ class Syncer(abc.ABC): raise NotImplementedError # pragma: no cover async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> None: - """ - Synchronise the database with the cache of `guild`. - - If `ctx` is given, send a message with the results. - """ + """If `ctx` is given, send a message with the results.""" log.info(f"Starting {self.name} syncer.") if ctx: @@ -136,111 +130,67 @@ class UserSyncer(Syncer): """Return the difference of users between the cache of `guild` and the database.""" log.trace("Getting the diff for users.") - users = await self._get_users() + users_to_create = [] + users_to_update = [] + seen_guild_users = set() - # Pack DB roles and guild roles into one common, hashable format. - # They're hashable so that they're easily comparable with sets later. - db_users = { - user_dict['id']: _User( - roles=tuple(sorted(user_dict.pop('roles'))), - **user_dict - ) - for user_dict in users - } - guild_users = { - member.id: _User( - id=member.id, - name=member.name, - discriminator=int(member.discriminator), - roles=tuple(sorted(role.id for role in member.roles)), - in_guild=True - ) - for member in guild.members - } + async for db_user in self._get_users(): + updated_fields = {} - users_to_create = set() - users_to_update = set() - - for db_user in db_users.values(): - guild_user = guild_users.get(db_user.id) - - if guild_user is not None: - if db_user != guild_user: - fields_to_none: dict = {} - - for field in _User._fields: - # Set un-changed values to None except ID to speed up API PATCH method. - if getattr(db_user, field) == getattr(guild_user, field) and field != "id": - fields_to_none[field] = None - - new_api_user = guild_user._replace(**fields_to_none) - users_to_update.add(new_api_user) - - elif db_user.in_guild: - # The user is known in the DB but not the guild, and the - # DB currently specifies that the user is a member of the guild. - # This means that the user has left since the last sync. - # Update the `in_guild` attribute of the user on the site - # to signify that the user left. - - # Set un-changed fields to None except ID as it is required by the API. - fields_to_none: dict = {field: None for field in db_user._fields if field not in ["id", "in_guild"]} - new_api_user = db_user._replace( - in_guild=False, - **fields_to_none - ) - users_to_update.add(new_api_user) - - new_user_ids = set(guild_users.keys()) - set(db_users.keys()) - for user_id in new_user_ids: - # The user is known on the guild but not on the API. This means - # that the user has joined since the last sync. Create it. - new_user = guild_users[user_id] - users_to_create.add(new_user) + def maybe_update(db_field: str, guild_value: t.Union[str, int]) -> None: + if db_user[db_field] != guild_value: + updated_fields[db_field] = guild_value - return _Diff(users_to_create, users_to_update, None) + if guild_user := guild.get_member(db_user["id"]): + seen_guild_users.add(guild_user.id) + + maybe_update("name", guild_user.name) + maybe_update("discriminator", int(guild_user.discriminator)) + maybe_update("in_guild", True) - async def _get_users(self, endpoint: str = "bot/users", query_params: list = None) -> t.List[dict]: - """GET all users recursively.""" - users = [] - response: dict = await self.bot.api_client.get(endpoint, params=query_params) - users.extend(response["results"]) + guild_roles = [role.id for role in guild_user.roles] + if set(db_user["roles"]) != set(guild_roles): + updated_fields["roles"] = guild_roles - # The `response` is paginated, hence check if next page exists. - if (next_page_url := response["next"]) is not None: - next_endpoint, query_params = self.get_endpoint(next_page_url) - users.extend(await self._get_users(next_endpoint, query_params)) - return users + elif db_user["in_guild"]: + updated_fields["in_guild"] = False - @staticmethod - def get_endpoint(url: str) -> t.Tuple[str, t.List[tuple]]: - """Extract the API endpoint and query params from a URL.""" - url = urlparse(url) + if updated_fields and updated_fields not in users_to_update: + updated_fields["id"] = db_user["id"] + users_to_update.append(updated_fields) - # Do not include starting `/` for endpoint. - endpoint = url.path[1:] + for member in guild.members: + if member.id not in seen_guild_users: + new_user = { + "id": member.id, + "name": member.name, + "discriminator": int(member.discriminator), + "roles": [role.id for role in member.roles], + "in_guild": True + } + if new_user not in users_to_create: + users_to_create.append(new_user) - # Query params. - params = parse_qsl(url.query) + return _Diff(users_to_create, users_to_update, None) - return endpoint, params + async def _get_users(self) -> t.AsyncIterable: + """GET users from database.""" + query_params = { + "page": 1 + } + while query_params["page"]: + res = await self.bot.api_client.get("bot/users", params=query_params) + for user in res["results"]: + yield user - @staticmethod - def patch_dict(user: _User) -> t.Dict[str, t.Union[int, str, tuple, bool]]: - """Convert namedtuple to dict by omitting None values.""" - user_dict = {} - for field in user._fields: - if (value := getattr(user, field)) is not None: - user_dict[field] = value - return user_dict + query_params["page"] = res["next_page_no"] async def _sync(self, diff: _Diff) -> None: """Synchronise the database with the user cache of `guild`.""" log.trace("Syncing created users...") if diff.created: - created = [user._asdict() for user in diff.created] - await self.bot.api_client.post("bot/users", json=created) + await self.bot.api_client.post("bot/users", json=diff.created) + log.trace("Syncing updated users...") if diff.updated: - updated = [self.patch_dict(user) for user in diff.updated] - await self.bot.api_client.patch("bot/users/bulk_patch", json=updated) + await self.bot.api_client.patch("bot/users/bulk_patch", json=diff.updated) -- cgit v1.2.3 From 6ee08368186716804121cb456783e3bc56ced7f3 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Wed, 7 Oct 2020 23:20:13 +0530 Subject: Refactor tests to use updated changes to syncer.py and API. --- tests/bot/exts/backend/sync/test_users.py | 117 +++++++++++++++--------------- 1 file changed, 59 insertions(+), 58 deletions(-) diff --git a/tests/bot/exts/backend/sync/test_users.py b/tests/bot/exts/backend/sync/test_users.py index c3a486743..9f380a15d 100644 --- a/tests/bot/exts/backend/sync/test_users.py +++ b/tests/bot/exts/backend/sync/test_users.py @@ -1,6 +1,6 @@ import unittest -from bot.exts.backend.sync._syncers import UserSyncer, _Diff, _User +from bot.exts.backend.sync._syncers import UserSyncer, _Diff from tests import helpers @@ -9,22 +9,12 @@ def fake_user(**kwargs): kwargs.setdefault("id", 43) kwargs.setdefault("name", "bob the test man") kwargs.setdefault("discriminator", 1337) - kwargs.setdefault("roles", (666,)) + kwargs.setdefault("roles", [666]) kwargs.setdefault("in_guild", True) return kwargs -def fake_none_user(**kwargs): - kwargs.setdefault("id", None) - kwargs.setdefault("name", None) - kwargs.setdefault("discriminator", None) - kwargs.setdefault("roles", None) - kwargs.setdefault("in_guild", None) - - return kwargs - - class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): """Tests for determining differences between users in the DB and users in the Guild cache.""" @@ -49,18 +39,26 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): return guild + @staticmethod + def get_mock_member(member: dict): + member = member.copy() + del member["in_guild"] + mock_member = helpers.MockMember(**member) + mock_member.roles = [helpers.MockRole(id=role_id) for role_id in member["roles"]] + return mock_member + async def test_empty_diff_for_no_users(self): """When no users are given, an empty diff should be returned.""" self.bot.api_client.get.return_value = { "count": 3, - "next": None, - "previous": None, + "next_page_no": None, + "previous_page_no": None, "results": [] } guild = self.get_guild() actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), set(), None) + expected_diff = ([], [], None) self.assertEqual(actual_diff, expected_diff) @@ -68,66 +66,75 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): """No differences should be found if the users in the guild and DB are identical.""" self.bot.api_client.get.return_value = { "count": 3, - "next": None, - "previous": None, + "next_page_no": None, + "previous_page_no": None, "results": [fake_user()] } guild = self.get_guild(fake_user()) + guild.get_member.return_value = self.get_mock_member(fake_user()) actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), set(), None) + expected_diff = ([], [], None) self.assertEqual(actual_diff, expected_diff) async def test_diff_for_updated_users(self): """Only updated users should be added to the 'updated' set of the diff.""" updated_user = fake_user(id=99, name="new") - updated_user_none = fake_none_user(id=99, name="new") self.bot.api_client.get.return_value = { "count": 3, - "next": None, - "previous": None, + "next_page_no": None, + "previous_page_no": None, "results": [fake_user(id=99, name="old"), fake_user()] } guild = self.get_guild(updated_user, fake_user()) + guild.get_member.side_effect = [ + self.get_mock_member(updated_user), + self.get_mock_member(fake_user()) + ] actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), {_User(**updated_user_none)}, None) + expected_diff = ([], [{"id": 99, "name": "new"}], None) self.assertEqual(actual_diff, expected_diff) async def test_diff_for_new_users(self): - """Only new users should be added to the 'created' set of the diff.""" + """Only new users should be added to the 'created' list of the diff.""" new_user = fake_user(id=99, name="new") self.bot.api_client.get.return_value = { "count": 3, - "next": None, - "previous": None, + "next_page_no": None, + "previous_page_no": None, "results": [fake_user()] } guild = self.get_guild(fake_user(), new_user) - + guild.get_member.side_effect = [ + self.get_mock_member(fake_user()), + self.get_mock_member(new_user) + ] actual_diff = await self.syncer._get_diff(guild) - expected_diff = ({_User(**new_user)}, set(), None) + expected_diff = ([new_user], [], None) self.assertEqual(actual_diff, expected_diff) async def test_diff_sets_in_guild_false_for_leaving_users(self): """When a user leaves the guild, the `in_guild` flag is updated to `False`.""" - leaving_user_none = fake_none_user(id=63, in_guild=False) - self.bot.api_client.get.return_value = { "count": 3, - "next": None, - "previous": None, + "next_page_no": None, + "previous_page_no": None, "results": [fake_user(), fake_user(id=63)] } guild = self.get_guild(fake_user()) + guild.get_member.side_effect = [ + self.get_mock_member(fake_user()), + None + ] actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), {_User(**leaving_user_none)}, None) + expected_diff = ([], [{"id": 63, "in_guild": False}], None) self.assertEqual(actual_diff, expected_diff) @@ -136,42 +143,41 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): new_user = fake_user(id=99, name="new") updated_user = fake_user(id=55, name="updated") - updated_user_none = fake_none_user(id=55, name="updated") - - leaving_user_none = fake_none_user(id=63, in_guild=False) self.bot.api_client.get.return_value = { "count": 3, - "next": None, - "previous": None, + "next_page_no": None, + "previous_page_no": None, "results": [fake_user(), fake_user(id=55), fake_user(id=63)] } guild = self.get_guild(fake_user(), new_user, updated_user) + guild.get_member.side_effect = [ + self.get_mock_member(fake_user()), + self.get_mock_member(updated_user), + None + ] actual_diff = await self.syncer._get_diff(guild) - expected_diff = ( - {_User(**new_user)}, - { - _User(**updated_user_none), - _User(**leaving_user_none) - }, - None - ) + expected_diff = ([new_user], [{"id": 55, "name": "updated"}, {"id": 63, "in_guild": False}], None) self.assertEqual(actual_diff, expected_diff) async def test_empty_diff_for_db_users_not_in_guild(self): - """When the DB knows a user the guild doesn't, no difference is found.""" + """When the DB knows a user, but the guild doesn't, no difference is found.""" self.bot.api_client.get.return_value = { "count": 3, - "next": None, - "previous": None, + "next_page_no": None, + "previous_page_no": None, "results": [fake_user(), fake_user(id=63, in_guild=False)] } guild = self.get_guild(fake_user()) + guild.get_member.side_effect = [ + self.get_mock_member(fake_user()), + None + ] actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), set(), None) + expected_diff = ([], [], None) self.assertEqual(actual_diff, expected_diff) @@ -187,13 +193,10 @@ class UserSyncerSyncTests(unittest.IsolatedAsyncioTestCase): """Only POST requests should be made with the correct payload.""" users = [fake_user(id=111), fake_user(id=222)] - user_tuples = {_User(**user) for user in users} - diff = _Diff(user_tuples, set(), None) + diff = _Diff(users, [], None) await self.syncer._sync(diff) - # Convert namedtuples to dicts as done in self.syncer._sync method. - created = [user._asdict() for user in diff.created] - self.bot.api_client.post.assert_called_once_with("bot/users", json=created) + self.bot.api_client.post.assert_called_once_with("bot/users", json=diff.created) self.bot.api_client.put.assert_not_called() self.bot.api_client.delete.assert_not_called() @@ -202,12 +205,10 @@ class UserSyncerSyncTests(unittest.IsolatedAsyncioTestCase): """Only PUT requests should be made with the correct payload.""" users = [fake_user(id=111), fake_user(id=222)] - user_tuples = {_User(**user) for user in users} - diff = _Diff(set(), user_tuples, None) + diff = _Diff([], users, None) await self.syncer._sync(diff) - updated = [self.syncer.patch_dict(user) for user in diff.updated] - self.bot.api_client.patch.assert_called_once_with("bot/users/bulk_patch", json=updated) + self.bot.api_client.patch.assert_called_once_with("bot/users/bulk_patch", json=diff.updated) self.bot.api_client.post.assert_not_called() self.bot.api_client.delete.assert_not_called() -- cgit v1.2.3 From 64b70160d63d28b1b2b2215cf484a825ca516160 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Wed, 7 Oct 2020 20:06:06 +0100 Subject: made sure to use sub_clyde on username passed to send_attachments --- bot/utils/messages.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 9fd571a20..b6c7cab50 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -68,10 +68,11 @@ async def send_attachments( embed which links to them. Extra kwargs will be passed to send() when sending the attachment. """ webhook_send_kwargs = { - 'username': sub_clyde(message.author.display_name), + 'username': message.author.display_name, 'avatar_url': message.author.avatar_url, } webhook_send_kwargs.update(kwargs) + webhook_send_kwargs['username'] = sub_clyde(webhook_send_kwargs['username']) large = [] urls = [] -- cgit v1.2.3 From 46bdcdf9414786f1432b4937590a0448122e6f34 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 7 Oct 2020 15:13:01 -0700 Subject: Silence tests: fix unawaited coro warnings Because the Scheduler is mocked, it doesn't actually do anything with the coroutines passed to the schedule() functions, hence the warnings. --- tests/bot/cogs/moderation/test_silence.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 5588115ae..6a8db72e8 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -68,7 +68,9 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): with self.subTest(current_loop=current_loop): with mock.patch.object(self.notifier, "_current_loop", new=current_loop): await self.notifier._notifier() - self.alert_channel.send.assert_called_once_with(f"<@&{Roles.moderators}> currently silenced channels: ") + self.alert_channel.send.assert_called_once_with( + f"<@&{Roles.moderators}> currently silenced channels: " + ) self.alert_channel.send.reset_mock() async def test_notifier_skips_alert(self): @@ -158,7 +160,7 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): async def test_skipped_missing_channel(self): """Did nothing because the channel couldn't be retrieved.""" - self.cog.unsilence_timestamps.items.return_value = [(123, -1), (123, 1), (123, 100000000000)] + self.cog.unsilence_timestamps.items.return_value = [(123, -1), (123, 1), (123, 10000000000)] self.bot.get_channel.return_value = None await self.cog._reschedule() @@ -230,6 +232,9 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.cog._init_task = asyncio.Future() self.cog._init_task.set_result(None) + # Avoid unawaited coroutine warnings. + self.cog.scheduler.schedule_later.side_effect = lambda delay, task_id, coro: coro.close() + asyncio.run(self.cog._async_init()) # Populate instance attributes. self.channel = MockTextChannel() -- cgit v1.2.3 From d0635ea328ed5bc659d77820752dedef3c19df0c Mon Sep 17 00:00:00 2001 From: mbaruh Date: Thu, 8 Oct 2020 01:21:19 +0300 Subject: adjusted prepare_input docs and unittests --- bot/exts/utils/snekbox.py | 8 +++++++- tests/bot/exts/utils/test_snekbox.py | 7 +++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index 77830209e..295c84901 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -74,7 +74,13 @@ class Snekbox(Cog): @staticmethod def prepare_input(code: str) -> str: - """Extract code from the Markdown, format it, and insert it into the code template.""" + """ + Extract code from the Markdown, format it, and insert it into the code template. + + If there is Markdown, ignores surrounding text. + If there are several Markdown parts in the message, concatenates only the code blocks. + If there is inline code but no code blocks, takes the first instance of inline code. + """ if match := list(FORMATTED_CODE_REGEX.finditer(code)): blocks = [block for block in match if block.group("block")] diff --git a/tests/bot/exts/utils/test_snekbox.py b/tests/bot/exts/utils/test_snekbox.py index 6601fad2c..9a42d0610 100644 --- a/tests/bot/exts/utils/test_snekbox.py +++ b/tests/bot/exts/utils/test_snekbox.py @@ -52,6 +52,13 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): ('`print("Hello world!")`', 'print("Hello world!")', 'one line code block'), ('```\nprint("Hello world!")```', 'print("Hello world!")', 'multiline code block'), ('```py\nprint("Hello world!")```', 'print("Hello world!")', 'multiline python code block'), + ('text```print("Hello world!")```text', 'print("Hello world!")', 'code block surrounded by text'), + ('```print("Hello world!")```\ntext\n```py\nprint("Hello world!")```', + 'print("Hello world!")\nprint("Hello world!")', 'two code blocks with text in-between'), + ('`print("Hello world!")`\ntext\n```print("How\'s it going?")```', + 'print("How\'s it going?")', 'code block preceded by inline code'), + ('`print("Hello world!")`\ntext\n`print("Hello world!")`', + 'print("Hello world!")', 'one inline code block of two') ) for case, expected, testname in cases: with self.subTest(msg=f'Extract code from {testname}.'): -- cgit v1.2.3 From b55ce89f01ef4d66a8b930dcbdc061cdef3563f3 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Thu, 8 Oct 2020 03:05:02 +0300 Subject: clarify prepare_input doc Co-authored-by: Mark --- bot/exts/utils/snekbox.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index 295c84901..da3e07f42 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -77,9 +77,9 @@ class Snekbox(Cog): """ Extract code from the Markdown, format it, and insert it into the code template. - If there is Markdown, ignores surrounding text. - If there are several Markdown parts in the message, concatenates only the code blocks. - If there is inline code but no code blocks, takes the first instance of inline code. + If there is any code block, ignore text outside the code block. + Use the first code block, but prefer a fenced code block. + If there are several fenced code blocks, concatenate only the fenced code blocks. """ if match := list(FORMATTED_CODE_REGEX.finditer(code)): blocks = [block for block in match if block.group("block")] -- cgit v1.2.3 From 586aeb66e9156259efbdfed43c11b66003185ad2 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Thu, 8 Oct 2020 10:40:11 +0530 Subject: remove redundant if statement --- bot/exts/backend/sync/_syncers.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index 70887a217..3a7719559 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -168,8 +168,7 @@ class UserSyncer(Syncer): "roles": [role.id for role in member.roles], "in_guild": True } - if new_user not in users_to_create: - users_to_create.append(new_user) + users_to_create.append(new_user) return _Diff(users_to_create, users_to_update, None) -- cgit v1.2.3 From 72819f275658f0637deb2d7fba9a838d65294203 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Thu, 8 Oct 2020 10:41:52 +0530 Subject: remove redundant if statement --- bot/exts/backend/sync/_syncers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index 3a7719559..c32038f4e 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -155,7 +155,7 @@ class UserSyncer(Syncer): elif db_user["in_guild"]: updated_fields["in_guild"] = False - if updated_fields and updated_fields not in users_to_update: + if updated_fields: updated_fields["id"] = db_user["id"] users_to_update.append(updated_fields) -- cgit v1.2.3 From 9af0883deeb57b08044400335c759a206d5833fb Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Thu, 8 Oct 2020 10:49:06 +0530 Subject: update documentation --- bot/exts/backend/sync/_syncers.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index c32038f4e..38468c2b1 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -40,7 +40,11 @@ class Syncer(abc.ABC): raise NotImplementedError # pragma: no cover async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> None: - """If `ctx` is given, send a message with the results.""" + """ + Synchronise the database with the cache of `guild`. + + If `ctx` is given, send a message with the results. + """ log.info(f"Starting {self.name} syncer.") if ctx: @@ -135,9 +139,11 @@ class UserSyncer(Syncer): seen_guild_users = set() async for db_user in self._get_users(): + # Store user fields which are to be updated. updated_fields = {} def maybe_update(db_field: str, guild_value: t.Union[str, int]) -> None: + # Equalize DB user and guild user attributes. if db_user[db_field] != guild_value: updated_fields[db_field] = guild_value @@ -153,6 +159,11 @@ class UserSyncer(Syncer): updated_fields["roles"] = guild_roles elif db_user["in_guild"]: + # The user is known in the DB but not the guild, and the + # DB currently specifies that the user is a member of the guild. + # This means that the user has left since the last sync. + # Update the `in_guild` attribute of the user on the site + # to signify that the user left. updated_fields["in_guild"] = False if updated_fields: @@ -161,6 +172,8 @@ class UserSyncer(Syncer): for member in guild.members: if member.id not in seen_guild_users: + # The user is known on the guild but not on the API. This means + # that the user has joined since the last sync. Create it. new_user = { "id": member.id, "name": member.name, -- cgit v1.2.3 From 47b06305f567f0ef2d8cb98c7357910cdb61fbd1 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Thu, 8 Oct 2020 17:11:56 +1000 Subject: Update bot/exts/moderation/infraction/infractions.py Co-authored-by: Dennis Pham --- bot/exts/moderation/infraction/infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 9d6de1a97..7cf7075e6 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -71,7 +71,7 @@ class Infractions(InfractionScheduler, commands.Cog): """Permanently ban a user for the given reason and stop watching them with Big Brother.""" await self.apply_ban(ctx, user, reason) - @command() + @command(aliases=('pban',)) async def purgeban( self, ctx: Context, -- cgit v1.2.3 From 888c427466b370b9ae51e47496e979c2b6faed0c Mon Sep 17 00:00:00 2001 From: Gustav Odinger Date: Thu, 8 Oct 2020 19:35:20 +0200 Subject: Fix millisecond time for command processing time - For the `.ping` command - Fixes a faulty convertion from seconds to milliseconds --- bot/exts/utils/ping.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/ping.py b/bot/exts/utils/ping.py index a9ca3dbeb..572fc934b 100644 --- a/bot/exts/utils/ping.py +++ b/bot/exts/utils/ping.py @@ -33,7 +33,7 @@ class Latency(commands.Cog): """ # datetime.datetime objects do not have the "milliseconds" attribute. # It must be converted to seconds before converting to milliseconds. - bot_ping = (datetime.utcnow() - ctx.message.created_at).total_seconds() / 1000 + bot_ping = (datetime.utcnow() - ctx.message.created_at).total_seconds() * 1000 bot_ping = f"{bot_ping:.{ROUND_LATENCY}f} ms" try: -- cgit v1.2.3 From d0c3990e8eb9e68537c05ec58594abdf5c4cee9e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 8 Oct 2020 12:25:41 -0700 Subject: Silence: add to notifier when indefinite rather than temporary Accidentally swapped the logic in a previous commit during a refactor. --- bot/cogs/moderation/silence.py | 2 +- tests/bot/cogs/moderation/test_silence.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 178dee06f..95706392a 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -116,10 +116,10 @@ class Silence(commands.Cog): await self._schedule_unsilence(ctx, duration) if duration is None: + self.notifier.add_channel(ctx.channel) log.info(f"Silenced {channel_info} indefinitely.") await ctx.send(MSG_SILENCE_PERMANENT) else: - self.notifier.add_channel(ctx.channel) log.info(f"Silenced {channel_info} for {duration} minute(s).") await ctx.send(MSG_SILENCE_SUCCESS.format(duration=duration)) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 6a8db72e8..50d8419ac 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -296,17 +296,17 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) - async def test_temp_added_to_notifier(self): - """Channel was added to notifier if a duration was set for the silence.""" + async def test_temp_not_added_to_notifier(self): + """Channel was not added to notifier if a duration was set for the silence.""" with mock.patch.object(self.cog, "_silence_overwrites", return_value=True): await self.cog.silence.callback(self.cog, MockContext(), 15) - self.cog.notifier.add_channel.assert_called_once() + self.cog.notifier.add_channel.assert_not_called() - async def test_indefinite_not_added_to_notifier(self): - """Channel was not added to notifier if a duration was not set for the silence.""" + async def test_indefinite_added_to_notifier(self): + """Channel was added to notifier if a duration was not set for the silence.""" with mock.patch.object(self.cog, "_silence_overwrites", return_value=True): await self.cog.silence.callback(self.cog, MockContext(), None) - self.cog.notifier.add_channel.assert_not_called() + self.cog.notifier.add_channel.assert_called_once() async def test_silenced_not_added_to_notifier(self): """Channel was not added to the notifier if it was already silenced.""" -- cgit v1.2.3 From 5b87a272ff21df9fa4fb59fdf9ec92c6b57193c6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 8 Oct 2020 13:22:54 -0700 Subject: Silence: remove _mod_log_channel attribute It's only used as an argument to `SilenceNotifier`, so it doesn't need to be an instance attribute. --- bot/cogs/moderation/silence.py | 3 +-- tests/bot/cogs/moderation/test_silence.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 95706392a..80c4e6a25 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -91,8 +91,7 @@ class Silence(commands.Cog): guild = self.bot.get_guild(Guild.id) self._verified_role = guild.get_role(Roles.verified) self._mod_alerts_channel = self.bot.get_channel(Channels.mod_alerts) - self._mod_log_channel = self.bot.get_channel(Channels.mod_log) - self.notifier = SilenceNotifier(self._mod_log_channel) + self.notifier = SilenceNotifier(self.bot.get_channel(Channels.mod_log)) await self._reschedule() @commands.command(aliases=("hush",)) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 50d8419ac..6f8f4386b 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -119,7 +119,7 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): mod_log = MockTextChannel() self.bot.get_channel.side_effect = (None, mod_log) await self.cog._async_init() - notifier.assert_called_once_with(self.cog._mod_log_channel) + notifier.assert_called_once_with(mod_log) @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_async_init_rescheduled(self): -- cgit v1.2.3 From e85a4d254cadd303537a4d2cce6637bbcd3cf2f9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 8 Oct 2020 13:23:35 -0700 Subject: Silence tests: make _async_init attribute tests more robust --- tests/bot/cogs/moderation/test_silence.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 6f8f4386b..3e1b963b0 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -102,24 +102,28 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_async_init_got_role(self): """Got `Roles.verified` role from guild.""" - await self.cog._async_init() guild = self.bot.get_guild() - guild.get_role.assert_called_once_with(Roles.verified) + guild.get_role.side_effect = lambda id_: Mock(id=id_) + + await self.cog._async_init() + self.assertEqual(self.cog._verified_role.id, Roles.verified) @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_async_init_got_channels(self): """Got channels from bot.""" + self.bot.get_channel.side_effect = lambda id_: MockTextChannel(id=id_) + await self.cog._async_init() - self.bot.get_channel.called_once_with(Channels.mod_alerts) - self.bot.get_channel.called_once_with(Channels.mod_log) + self.assertEqual(self.cog._mod_alerts_channel.id, Channels.mod_alerts) @autospec(silence, "SilenceNotifier") async def test_async_init_got_notifier(self, notifier): """Notifier was started with channel.""" - mod_log = MockTextChannel() - self.bot.get_channel.side_effect = (None, mod_log) + self.bot.get_channel.side_effect = lambda id_: MockTextChannel(id=id_) + await self.cog._async_init() - notifier.assert_called_once_with(mod_log) + notifier.assert_called_once_with(MockTextChannel(id=Channels.mod_log)) + self.assertEqual(self.cog.notifier, notifier.return_value) @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_async_init_rescheduled(self): -- cgit v1.2.3 From 59795ad20ff8a48cb1773ad02135a3da2d6e5eb9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 9 Oct 2020 13:39:27 -0700 Subject: Silence: fix scheduled tasks not being cancelled on unload --- bot/cogs/moderation/silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 80c4e6a25..c54f9d849 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -234,7 +234,7 @@ class Silence(commands.Cog): # more tasks after cancel_all has finished, despite _init_task.cancel being called first. # This is cause cancel() on its own doesn't block until the task is cancelled. self._init_task.cancel() - self._init_task.add_done_callback(lambda _: self.scheduler.cancel_all) + self._init_task.add_done_callback(lambda _: self.scheduler.cancel_all()) # This cannot be static (must have a __func__ attribute). def cog_check(self, ctx: Context) -> bool: -- cgit v1.2.3 From bfab4928e5b219660f76e2516c4ec0bb67fcba89 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 9 Oct 2020 18:26:03 -0700 Subject: Silence: require only 1 permission to be False for a manual unsilence Previously, both sending messages and adding reactions had to be false in order for the manual unsilence failure message to be sent. Because staff may only set one of these manually, the message should be sent if at least one of the permissions is set. --- bot/exts/moderation/silence.py | 2 +- tests/bot/exts/moderation/test_silence.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index bb8e06924..ee2c0dc7c 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -136,7 +136,7 @@ class Silence(commands.Cog): """Unsilence `channel` and send a success/failure message.""" if not await self._unsilence(channel): overwrite = channel.overwrites_for(self._verified_role) - if overwrite.send_messages is False and overwrite.add_reactions is False: + if overwrite.send_messages is False or overwrite.add_reactions is False: await channel.send(MSG_UNSILENCE_MANUAL) else: await channel.send(MSG_UNSILENCE_FAIL) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 39e32fdb2..6d5ffa7e8 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -411,6 +411,8 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): (True, silence.MSG_UNSILENCE_SUCCESS, unsilenced_overwrite), (False, silence.MSG_UNSILENCE_FAIL, unsilenced_overwrite), (False, silence.MSG_UNSILENCE_MANUAL, self.overwrite), + (False, silence.MSG_UNSILENCE_MANUAL, PermissionOverwrite(send_messages=False)), + (False, silence.MSG_UNSILENCE_MANUAL, PermissionOverwrite(add_reactions=False)), ) for was_unsilenced, message, overwrite in test_cases: ctx = MockContext() -- cgit v1.2.3 From e1b7b48db3a1510dd2defd9879c12b85929f7364 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 9 Oct 2020 18:31:13 -0700 Subject: Silence: amend the manual unsilence message Clarify that this situation could also result from the cache being cleared prematurely. There's no way to distinguish the two scenarios, so a manual unsilence is required for both. --- bot/exts/moderation/silence.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index ee2c0dc7c..cfdefe103 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -22,8 +22,9 @@ MSG_SILENCE_SUCCESS = f"{Emojis.check_mark} silenced current channel for {{durat MSG_UNSILENCE_FAIL = f"{Emojis.cross_mark} current channel was not silenced." MSG_UNSILENCE_MANUAL = ( - f"{Emojis.cross_mark} current channel was not unsilenced because the current " - f"overwrites were set manually. Please edit them manually to unsilence." + f"{Emojis.cross_mark} current channel was not unsilenced because the current overwrites were " + f"set manually or the cache was prematurely cleared. " + f"Please edit the overwrites manually to unsilence." ) MSG_UNSILENCE_SUCCESS = f"{Emojis.check_mark} unsilenced current channel." -- cgit v1.2.3 From 90356113d6bf75a9567af5be22cbe5422f2cab4d Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 10 Oct 2020 17:39:51 +0300 Subject: Create base Voice Gate cog --- bot/exts/moderation/voice_gate.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 bot/exts/moderation/voice_gate.py diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py new file mode 100644 index 000000000..198617857 --- /dev/null +++ b/bot/exts/moderation/voice_gate.py @@ -0,0 +1,15 @@ +from discord.ext.commands import Cog + +from bot.bot import Bot + + +class VoiceGate(Cog): + """Voice channels verification management.""" + + def __init__(self, bot: Bot): + self.bot = bot + + +def setup(bot: Bot) -> None: + """Loads the VoiceGate cog.""" + bot.add_cog(VoiceGate(bot)) -- cgit v1.2.3 From 7039702ef29f4dd44db2f08005ac61d6ab83460f Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 10 Oct 2020 17:42:06 +0300 Subject: Define Voice Gate channel, role and requirement in constants.py --- bot/constants.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index bb82b976d..ccc3d505d 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -423,6 +423,7 @@ class Channels(metaclass=YAMLGetter): user_event_announcements: int user_log: int verification: int + voice_gate: int voice_log: int @@ -458,6 +459,7 @@ class Roles(metaclass=YAMLGetter): team_leaders: int unverified: int verified: int # This is the Developers role on PyDis, here named verified for readability reasons. + voice_verified: int class Guild(metaclass=YAMLGetter): @@ -577,6 +579,14 @@ class Verification(metaclass=YAMLGetter): kick_confirmation_threshold: float +class VoiceGate(metaclass=YAMLGetter): + section = "voice_gate" + + minimum_days_verified: int + minimum_messages: int + bot_message_delete_delay: int + + class Event(Enum): """ Event names. This does not include every event (for example, raw -- cgit v1.2.3 From 80409d40d0f9d39d08b287d5db460fba7c26ea0d Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 10 Oct 2020 17:50:27 +0300 Subject: Add voice gate configuration to config-default.yml --- config-default.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/config-default.yml b/config-default.yml index 3de83dbb1..2d70c17e4 100644 --- a/config-default.yml +++ b/config-default.yml @@ -481,5 +481,11 @@ verification: kick_confirmation_threshold: 0.01 # 1% +voice_gate: + minimum_days_verified: 3 # Days how much user have to be verified to pass Voice Gate + minimum_messages: 50 # How much messages user must have to pass Voice Gate + bot_message_delete_delay: 10 # Seconds before deleting bot's response in Voice Gate + + config: required_keys: ['bot.token'] -- cgit v1.2.3 From f76bced0f77cd36a2ce25ff11717c2d277c3de60 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sat, 10 Oct 2020 18:38:10 +0200 Subject: Duckpond: Add a list of already ducked messages Previously race conditions caused the messages to be processed again before knowing the white check mark reaction got added, this seems to solve it --- bot/exts/fun/duck_pond.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index 82084ea88..48aa2749c 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -22,6 +22,7 @@ class DuckPond(Cog): self.bot = bot self.webhook_id = constants.Webhooks.duck_pond self.webhook = None + self.ducked_messages = [] self.bot.loop.create_task(self.fetch_webhook()) self.relay_lock = None @@ -176,7 +177,8 @@ class DuckPond(Cog): duck_count = await self.count_ducks(message) # If we've got more than the required amount of ducks, send the message to the duck_pond. - if duck_count >= constants.DuckPond.threshold: + if duck_count >= constants.DuckPond.threshold and message.id not in self.ducked_messages: + self.ducked_messages.append(message.id) await self.locked_relay(message) @Cog.listener() -- cgit v1.2.3 From a660a1ef1ed7d93bff6bf4cb1cdff279a1083324 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 08:07:19 +0300 Subject: Add Metricity DB URL to site (docker-compose.yml) --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yml b/docker-compose.yml index cff7d33d6..8be5aac0e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,7 @@ services: - postgres environment: DATABASE_URL: postgres://pysite:pysite@postgres:5432/pysite + METRICITY_DB_URL: postgres://pysite:pysite@postgres:5432/metricity SECRET_KEY: suitable-for-development-only STATIC_ROOT: /var/www/static -- cgit v1.2.3 From 9c1f66e43ed35d9fe8ffdc3ae0a4bb7504bb9c93 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 10:10:21 +0300 Subject: Add voice ban icons and show appeal footer for voice ban --- bot/exts/moderation/infraction/_utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index 1d91964f1..bff5fcf4c 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -18,9 +18,10 @@ INFRACTION_ICONS = { "note": (Icons.user_warn, None), "superstar": (Icons.superstarify, Icons.unsuperstarify), "warning": (Icons.user_warn, None), + "voice_ban": (Icons.voice_state_red, Icons.voice_state_green), } RULES_URL = "https://pythondiscord.com/pages/rules" -APPEALABLE_INFRACTIONS = ("ban", "mute") +APPEALABLE_INFRACTIONS = ("ban", "mute", "voice_ban") # Type aliases UserObject = t.Union[discord.Member, discord.User] -- cgit v1.2.3 From a4d445a61e06d47afd7cbb152ef4a93a73e6042a Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 10:10:47 +0300 Subject: Implement voice bans (temporary and permanent) --- bot/exts/moderation/infraction/infractions.py | 85 +++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 7cf7075e6..93ec59809 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -15,6 +15,7 @@ from bot.decorators import respect_role_hierarchy from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler from bot.exts.moderation.infraction._utils import UserSnowflake +from bot.utils.checks import has_any_role_check, has_no_roles_check from bot.utils.messages import format_user log = logging.getLogger(__name__) @@ -31,6 +32,7 @@ class Infractions(InfractionScheduler, commands.Cog): self.category = "Moderation" self._muted_role = discord.Object(constants.Roles.muted) + self._voice_verified_role = discord.Object(constants.Roles.voice_verified) @commands.Cog.listener() async def on_member_join(self, member: Member) -> None: @@ -88,6 +90,11 @@ class Infractions(InfractionScheduler, commands.Cog): """ await self.apply_ban(ctx, user, reason, max(min(purge_days, 7), 0)) + @command(aliases=('vban', 'voiceban')) + async def voice_ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str]) -> None: + """Permanently ban user from using voice channels.""" + await self.apply_voice_ban(ctx, user, reason) + # endregion # region: Temporary infractions @@ -136,6 +143,32 @@ class Infractions(InfractionScheduler, commands.Cog): """ await self.apply_ban(ctx, user, reason, expires_at=duration) + @command(aliases=("tempvban", "tvban")) + async def tempvoiceban( + self, + ctx: Context, + user: FetchedMember, + duration: Expiry, + *, + reason: t.Optional[str] + ) -> None: + """ + Temporarily voice ban a user for the given reason and duration. + + 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. + """ + await self.apply_voice_ban(ctx, user, reason, expires_at=duration) + # endregion # region: Permanent shadow infractions @@ -225,6 +258,11 @@ class Infractions(InfractionScheduler, commands.Cog): """Prematurely end the active ban infraction for the user.""" await self.pardon_infraction(ctx, "ban", user) + @command(aliases=("uvban",)) + async def unvoiceban(self, ctx: Context, user: FetchedMember) -> None: + """Prematurely end the active voice ban infraction for the user.""" + await self.pardon_infraction(ctx, "voice_ban", user) + # endregion # region: Base apply functions @@ -319,6 +357,25 @@ class Infractions(InfractionScheduler, commands.Cog): bb_reason = "User has been permanently banned from the server. Automatically removed." await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False) + @respect_role_hierarchy(member_arg=2) + async def apply_voice_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None: + """Apply a voice ban infraction with kwargs passed to `post_infraction`.""" + if constants.Roles.voice_verified not in [role.id for role in user.roles]: + await ctx.send(":x: Can't apply Voice Ban to user who have not passed the Voice Gate.") + return + + if await _utils.get_active_infraction(ctx, user, "voice_ban"): + return + + infraction = await _utils.post_infraction(ctx, user, "voice_ban", reason, active=True, **kwargs) + if infraction is None: + return + + self.mod_log.ignore(Event.member_update, user.id) + + action = user.remove_roles(self._voice_verified_role, reason=reason) + await self.apply_infraction(ctx, infraction, user, action) + # endregion # region: Base pardon functions @@ -363,6 +420,32 @@ class Infractions(InfractionScheduler, commands.Cog): return log_text + async def pardon_voice_ban(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]: + """Add Voice Verified role back to user, DM them a notification, and return a log dict.""" + user = guild.get_member(user_id) + log_text = {} + + if user: + # Add Voice Verified role back to user. + self.mod_log.ignore(Event.member_update, user.id) + await user.add_roles(self._voice_verified_role, reason=reason) + + # DM user about infraction expiration + notified = await _utils.notify_pardon( + user=user, + title="Your Voice Ban have been removed", + content="You can now speak again in voice channels.", + icon_url=_utils.INFRACTION_ICONS["voice_ban"][1] + ) + + log_text["Member"] = format_user(user) + log_text["DM"] = "Sent" if notified else "**Failed**" + else: + log.info(f"Failed to remove Voice Ban from user {user_id}: user not found") + log_text["Failure"] = "User was not found in the guild." + + return log_text + async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]: """ Execute deactivation steps specific to the infraction's type and return a log dict. @@ -377,6 +460,8 @@ class Infractions(InfractionScheduler, commands.Cog): return await self.pardon_mute(user_id, guild, reason) elif infraction["type"] == "ban": return await self.pardon_ban(user_id, guild, reason) + elif infraction["type"] == "voice_ban": + return await self.pardon_voice_ban(user_id, guild, reason) # endregion -- cgit v1.2.3 From 247e866868a7f0687ceb02a64beb79ebcbb440e5 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 10:11:25 +0300 Subject: Remove not used imports --- bot/exts/moderation/infraction/infractions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 93ec59809..2157c040c 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -15,7 +15,6 @@ from bot.decorators import respect_role_hierarchy from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler from bot.exts.moderation.infraction._utils import UserSnowflake -from bot.utils.checks import has_any_role_check, has_no_roles_check from bot.utils.messages import format_user log = logging.getLogger(__name__) -- cgit v1.2.3 From 0147934b7681cd65496f904e0d8ab15b4331d7c4 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 11:35:43 +0300 Subject: Implement Voice Verifying command and delete message in voice gate --- bot/exts/moderation/voice_gate.py | 112 +++++++++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 198617857..dae19d49e 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -1,6 +1,26 @@ -from discord.ext.commands import Cog +import logging +from contextlib import suppress +from datetime import datetime, timedelta +import discord +from dateutil import parser + +from discord.ext.commands import Cog, Context, command + +from bot.api import ResponseCodeError from bot.bot import Bot +from bot.constants import Channels, Roles, VoiceGate as VoiceGateConf, MODERATION_ROLES, Event +from bot.decorators import has_no_roles, in_whitelist +from bot.exts.moderation.modlog import ModLog + +log = logging.getLogger(__name__) + +# Messages for case when user don't meet with requirements +NOT_ENOUGH_MESSAGES = f"haven't sent at least {VoiceGateConf.minimum_messages} messages" +NOT_ENOUGH_DAYS_AFTER_VERIFICATION = f"haven't been verified for at least {VoiceGateConf.minimum_days_verified} days" +VOICE_BANNED = "are voice banned" + +FAILED_MESSAGE = """{user} you don't meet with our current requirements to pass Voice Gate. You {reasons}.""" class VoiceGate(Cog): @@ -9,6 +29,96 @@ class VoiceGate(Cog): def __init__(self, bot: Bot): self.bot = bot + @property + def mod_log(self) -> ModLog: + """Get the currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + @command(aliases=('voiceverify', 'vverify', 'voicev', 'vv')) + @has_no_roles(Roles.voice_verified) + @in_whitelist(channels=(Channels.voice_gate,), redirect=None) + async def voice_verify(self, ctx: Context, *_) -> None: + """ + Apply to be able to use voice within the Discord server. + + In order to use voice you must meet all three of the following criteria: + - You must have over a certain number of messages within the Discord server + - You must have accepted our rules over a certain number of days ago + - You must not be actively banned from using our voice channels + """ + try: + data = await self.bot.api_client.get(f"bot/users/{ctx.author.id}/metricity_data") + except ResponseCodeError as e: + if e.status == 404: + await ctx.send(f":x: {ctx.author.mention} Unable to find Metricity data about you.") + log.info(f"Unable to find Metricity data about {ctx.author} ({ctx.author.id})") + else: + log.warning(f"Got response code {e.status} while trying to get {ctx.author.id} metricity data.") + await ctx.send(f":x: Got unexpected response from site. Please let us know about this.") + return + + # Pre-parse this for better code style + data["verified_at"] = parser.isoparse(data["verified_at"]) + + failed = False + failed_reasons = [] + + if data["verified_at"] > datetime.utcnow() - timedelta(days=VoiceGateConf.minimum_days_verified): + failed_reasons.append(NOT_ENOUGH_DAYS_AFTER_VERIFICATION) + failed = True + self.bot.stats.incr("voice_gate.failed.verified_at") + if data["total_messages"] < VoiceGateConf.minimum_messages: + failed_reasons.append(NOT_ENOUGH_MESSAGES) + failed = True + self.bot.stats.incr("voice_gate.failed.total_messages") + if data["voice_banned"]: + failed_reasons.append(VOICE_BANNED) + failed = True + self.bot.stats.incr("voice_gate.failed.voice_banned") + + if failed: + if len(failed_reasons) > 1: + reasons = f"{', '.join(failed_reasons[:-1])} and {failed_reasons[-1]}" + else: + reasons = failed_reasons[0] + + await ctx.send( + FAILED_MESSAGE.format( + user=ctx.author.mention, + reasons=reasons + ) + ) + return + + self.mod_log.ignore(Event.member_update, ctx.author.id) + await ctx.author.add_roles(discord.Object(Roles.voice_verified), reason="Voice Gate passed") + await ctx.author.send( + ":tada: Congratulations! You are now Voice Verified and have access to PyDis Voice Channels." + ) + self.bot.stats.incr("voice_gate.passed") + + @Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """Delete all non-staff messages from voice gate channel that don't invoke voice verify command.""" + # Check is channel voice gate + if message.channel.id != Channels.voice_gate: + return + + # When it's bot sent message, delete it after some time + if message.author.bot: + with suppress(discord.NotFound): + await message.delete(delay=VoiceGateConf.bot_message_delete_delay) + return + + # Then check is member moderator+, because we don't want to delete their messages. + if any(role.id in MODERATION_ROLES for role in message.author.roles): + log.trace(f"Excluding moderator message {message.id} from deletion in #{message.channel}.") + return + + self.mod_log.ignore(Event.message_delete, message.id) + with suppress(discord.NotFound): + await message.delete() + def setup(bot: Bot) -> None: """Loads the VoiceGate cog.""" -- cgit v1.2.3 From 22e9c04d63c4a983448efc91a12335a326393e76 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 12:23:02 +0300 Subject: Suppress Voice Gate cog InWhiteListCheckFailure --- bot/exts/moderation/voice_gate.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index dae19d49e..101db90b8 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -12,6 +12,7 @@ from bot.bot import Bot from bot.constants import Channels, Roles, VoiceGate as VoiceGateConf, MODERATION_ROLES, Event from bot.decorators import has_no_roles, in_whitelist from bot.exts.moderation.modlog import ModLog +from bot.utils.checks import InWhitelistCheckFailure log = logging.getLogger(__name__) @@ -119,6 +120,11 @@ class VoiceGate(Cog): with suppress(discord.NotFound): await message.delete() + async def cog_command_error(self, ctx: Context, error: Exception) -> None: + """Check for & ignore any InWhitelistCheckFailure.""" + if isinstance(error, InWhitelistCheckFailure): + error.handled = True + def setup(bot: Bot) -> None: """Loads the VoiceGate cog.""" -- cgit v1.2.3 From 002c53cb922f826c33c58fe35afccee24d5b2689 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 12:52:12 +0300 Subject: Improve voice gate messages deletion --- bot/exts/moderation/voice_gate.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 101db90b8..bd2afb464 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -105,6 +105,9 @@ class VoiceGate(Cog): if message.channel.id != Channels.voice_gate: return + ctx = await self.bot.get_context(message) + is_verify_command = ctx.command is not None and ctx.command.name == "voice_verify" + # When it's bot sent message, delete it after some time if message.author.bot: with suppress(discord.NotFound): @@ -112,11 +115,14 @@ class VoiceGate(Cog): return # Then check is member moderator+, because we don't want to delete their messages. - if any(role.id in MODERATION_ROLES for role in message.author.roles): + if any(role.id in MODERATION_ROLES for role in message.author.roles) and is_verify_command == False: log.trace(f"Excluding moderator message {message.id} from deletion in #{message.channel}.") return - self.mod_log.ignore(Event.message_delete, message.id) + # Ignore deleted voice verification messages + if ctx.command is not None and ctx.command.name == "voice_verify": + self.mod_log.ignore(Event.message_delete, message.id) + with suppress(discord.NotFound): await message.delete() -- cgit v1.2.3 From 4d967cd27d049bffc2585d2cc8f381f44f59ca61 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 13:04:35 +0300 Subject: Create test for permanent voice ban --- .../bot/exts/moderation/infraction/test_infractions.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index be1b649e1..27f346648 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -53,3 +53,20 @@ class TruncationTests(unittest.IsolatedAsyncioTestCase): self.cog.apply_infraction.assert_awaited_once_with( self.ctx, {"foo": "bar"}, self.target, self.target.kick.return_value ) + + +class VoiceBanTests(unittest.IsolatedAsyncioTestCase): + """Tests for voice ban related functions and commands.""" + + def setUp(self): + self.bot = MockBot() + self.mod = MockMember() + self.user = MockMember() + self.ctx = MockContext(bot=self.bot, author=self.mod) + self.cog = Infractions(self.bot) + + async def test_permanent_voice_ban(self): + """Should call voice ban applying function.""" + self.cog.apply_voice_ban = AsyncMock() + self.assertIsNone(await self.cog.voice_ban(self.cog, self.ctx, self.user, reason="foobar")) + self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar") -- cgit v1.2.3 From b792af63022bf8e435210c9efefccc664c3bbf80 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 13:08:27 +0300 Subject: Create test for temporary voice ban --- tests/bot/exts/moderation/infraction/test_infractions.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 27f346648..814959775 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -66,7 +66,13 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.cog = Infractions(self.bot) async def test_permanent_voice_ban(self): - """Should call voice ban applying function.""" + """Should call voice ban applying function without expiry.""" self.cog.apply_voice_ban = AsyncMock() self.assertIsNone(await self.cog.voice_ban(self.cog, self.ctx, self.user, reason="foobar")) self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar") + + async def test_temporary_voice_ban(self): + """Should call voice ban applying function with expiry.""" + self.cog.apply_voice_ban = AsyncMock() + self.assertIsNone(await self.cog.tempvoiceban(self.cog, self.ctx, self.user, "baz", reason="foobar")) + self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at="baz") -- cgit v1.2.3 From 2b701b05b55d6c62c27497d39b142370693ef88d Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 13:13:23 +0300 Subject: Create test for voice unban --- tests/bot/exts/moderation/infraction/test_infractions.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 814959775..02062932e 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -76,3 +76,9 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.cog.apply_voice_ban = AsyncMock() self.assertIsNone(await self.cog.tempvoiceban(self.cog, self.ctx, self.user, "baz", reason="foobar")) self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at="baz") + + async def test_voice_unban(self): + """Should call infraction pardoning function.""" + self.cog.pardon_infraction = AsyncMock() + self.assertIsNone(await self.cog.unvoiceban(self.cog, self.ctx, self.user)) + self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_ban", self.user) -- cgit v1.2.3 From 8faa82f7d7de795b4a8e2fc7a6dc919994258d6c Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:00:09 +0300 Subject: Create test for case when trying to voice ban user who haven't passed gate --- tests/bot/exts/moderation/infraction/test_infractions.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 02062932e..b2b617e51 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -60,8 +60,8 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): def setUp(self): self.bot = MockBot() - self.mod = MockMember() - self.user = MockMember() + self.mod = MockMember(top_role=10) + self.user = MockMember(top_role=1) self.ctx = MockContext(bot=self.bot, author=self.mod) self.cog = Infractions(self.bot) @@ -82,3 +82,12 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.cog.pardon_infraction = AsyncMock() self.assertIsNone(await self.cog.unvoiceban(self.cog, self.ctx, self.user)) self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_ban", self.user) + + @patch("bot.exts.moderation.infraction.infractions.constants.Roles.voice_verified", new=123456) + @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") + async def test_voice_ban_not_having_voice_verified_role(self, get_active_infraction_mock): + """Should send message and not apply infraction when user don't have voice verified role.""" + self.user.roles = [MockRole(id=987)] + self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) + self.ctx.send.assert_awaited_once() + get_active_infraction_mock.assert_not_awaited() -- cgit v1.2.3 From 55a46c937de9c27cd865ff34cfe82c8fb76dc603 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:01:32 +0300 Subject: Simplify post infraction calling and None check --- bot/exts/moderation/infraction/infractions.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 2157c040c..6a6250238 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -366,8 +366,7 @@ class Infractions(InfractionScheduler, commands.Cog): if await _utils.get_active_infraction(ctx, user, "voice_ban"): return - infraction = await _utils.post_infraction(ctx, user, "voice_ban", reason, active=True, **kwargs) - if infraction is None: + if infraction := await _utils.post_infraction(ctx, user, "voice_ban", reason, active=True, **kwargs): return self.mod_log.ignore(Event.member_update, user.id) -- cgit v1.2.3 From b7a072c1c43ad5b0779c1e979a1870c002cfd5c3 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:05:42 +0300 Subject: Create test for case when user already have active Voice Ban --- tests/bot/exts/moderation/infraction/test_infractions.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index b2b617e51..510f31db3 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -55,13 +55,14 @@ class TruncationTests(unittest.IsolatedAsyncioTestCase): ) +@patch("bot.exts.moderation.infraction.infractions.constants.Roles.voice_verified", new=123456) class VoiceBanTests(unittest.IsolatedAsyncioTestCase): """Tests for voice ban related functions and commands.""" def setUp(self): self.bot = MockBot() self.mod = MockMember(top_role=10) - self.user = MockMember(top_role=1) + self.user = MockMember(top_role=1, roles=[MockRole(id=123456)]) self.ctx = MockContext(bot=self.bot, author=self.mod) self.cog = Infractions(self.bot) @@ -83,7 +84,6 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.unvoiceban(self.cog, self.ctx, self.user)) self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_ban", self.user) - @patch("bot.exts.moderation.infraction.infractions.constants.Roles.voice_verified", new=123456) @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") async def test_voice_ban_not_having_voice_verified_role(self, get_active_infraction_mock): """Should send message and not apply infraction when user don't have voice verified role.""" @@ -91,3 +91,12 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) self.ctx.send.assert_awaited_once() get_active_infraction_mock.assert_not_awaited() + + @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") + @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") + async def test_voice_ban_user_have_active_infraction(self, get_active_infraction, post_infraction_mock): + """Should return early when user already have Voice Ban infraction.""" + get_active_infraction.return_value = {"foo": "bar"} + self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) + get_active_infraction.assert_awaited_once() + post_infraction_mock.assert_not_awaited() -- cgit v1.2.3 From a1209554614e3f5b63ab400a754f1d893896754b Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:11:03 +0300 Subject: Revert recent walrus operator change --- bot/exts/moderation/infraction/infractions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 6a6250238..2157c040c 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -366,7 +366,8 @@ class Infractions(InfractionScheduler, commands.Cog): if await _utils.get_active_infraction(ctx, user, "voice_ban"): return - if infraction := await _utils.post_infraction(ctx, user, "voice_ban", reason, active=True, **kwargs): + infraction = await _utils.post_infraction(ctx, user, "voice_ban", reason, active=True, **kwargs) + if infraction is None: return self.mod_log.ignore(Event.member_update, user.id) -- cgit v1.2.3 From c719169bffcca8898ced04c1fed0264a5b9cd7f6 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:11:36 +0300 Subject: Create test for case when posting infraction fails --- tests/bot/exts/moderation/infraction/test_infractions.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 510f31db3..1c3294b39 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -1,6 +1,6 @@ import textwrap import unittest -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, Mock, patch, MagicMock from bot.exts.moderation.infraction.infractions import Infractions from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole @@ -100,3 +100,14 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) get_active_infraction.assert_awaited_once() post_infraction_mock.assert_not_awaited() + + @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") + @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") + async def test_voice_ban_infraction_post_failed(self, get_active_infraction, post_infraction_mock): + """Should return early when posting infraction fails.""" + self.cog.mod_log.ignore = MagicMock() + get_active_infraction.return_value = None + post_infraction_mock.return_value = None + self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) + post_infraction_mock.assert_awaited_once() + self.cog.mod_log.ignore.assert_not_called() -- cgit v1.2.3 From 2a6f86b87aa7bc19a26df739111a678f8fa03083 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:15:16 +0300 Subject: Create test to check does this pass proper kwargs to infraction posting --- tests/bot/exts/moderation/infraction/test_infractions.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 1c3294b39..ebb39320a 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -111,3 +111,15 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) post_infraction_mock.assert_awaited_once() self.cog.mod_log.ignore.assert_not_called() + + @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") + @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") + async def test_voice_ban_infraction_post_add_kwargs(self, get_active_infraction, post_infraction_mock): + """Should pass all kwargs passed to apply_voice_ban to post_infraction.""" + get_active_infraction.return_value = None + # We don't want that this continue yet + post_infraction_mock.return_value = None + self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar", my_kwarg=23)) + post_infraction_mock.assert_awaited_once_with( + self.ctx, self.user, "voice_ban", "foobar", active=True, my_kwarg=23 + ) -- cgit v1.2.3 From 06343b5b24aa2b5e9d7d34e39ff604ec4577bcd8 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:16:33 +0300 Subject: Check arguments for get_active_infraction in voice ban tests --- tests/bot/exts/moderation/infraction/test_infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index ebb39320a..37848e9e8 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -98,7 +98,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): """Should return early when user already have Voice Ban infraction.""" get_active_infraction.return_value = {"foo": "bar"} self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) - get_active_infraction.assert_awaited_once() + get_active_infraction.assert_awaited_once_with(self.ctx, self.user, "voice_ban") post_infraction_mock.assert_not_awaited() @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") -- cgit v1.2.3 From a4036476bca02cf645c459510c3866c6442020c7 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:22:37 +0300 Subject: Create test for voice ban applying role remove ignore. --- tests/bot/exts/moderation/infraction/test_infractions.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 37848e9e8..d4fb2b119 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -2,6 +2,7 @@ import textwrap import unittest from unittest.mock import AsyncMock, Mock, patch, MagicMock +from bot.constants import Event from bot.exts.moderation.infraction.infractions import Infractions from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole @@ -123,3 +124,17 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): post_infraction_mock.assert_awaited_once_with( self.ctx, self.user, "voice_ban", "foobar", active=True, my_kwarg=23 ) + + @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") + @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") + async def test_voice_ban_mod_log_ignore(self, get_active_infraction, post_infraction_mock): + """Should ignore Voice Verified role removing.""" + self.cog.mod_log.ignore = MagicMock() + self.cog.apply_infraction = AsyncMock() + self.user.remove_roles = MagicMock(return_value="my_return_value") + + get_active_infraction.return_value = None + post_infraction_mock.return_value = {"foo": "bar"} + + self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) + self.cog.mod_log.ignore.assert_called_once_with(Event.member_update, self.user.id) -- cgit v1.2.3 From d9d3b1a3615f347958cd8e194323b0c9b13d6a35 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:26:40 +0300 Subject: Add Voice Ban test about calling apply_infraction --- tests/bot/exts/moderation/infraction/test_infractions.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index d4fb2b119..1f4a3e7f0 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -138,3 +138,18 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) self.cog.mod_log.ignore.assert_called_once_with(Event.member_update, self.user.id) + + @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") + @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") + async def test_voice_ban_apply_infraction(self, get_active_infraction, post_infraction_mock): + """Should ignore Voice Verified role removing.""" + self.cog.mod_log.ignore = MagicMock() + self.cog.apply_infraction = AsyncMock() + self.user.remove_roles = MagicMock(return_value="my_return_value") + + get_active_infraction.return_value = None + post_infraction_mock.return_value = {"foo": "bar"} + + self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) + self.user.remove_roles.assert_called_once_with(self.cog._voice_verified_role, reason="foobar") + self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, "my_return_value") -- cgit v1.2.3 From fda0359abfe8644cc2a9452c19713395dec16dab Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:41:51 +0300 Subject: Shorten voice ban reason and create test for it --- bot/exts/moderation/infraction/infractions.py | 3 +++ .../bot/exts/moderation/infraction/test_infractions.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 2157c040c..0dab3a72e 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -372,6 +372,9 @@ class Infractions(InfractionScheduler, commands.Cog): self.mod_log.ignore(Event.member_update, user.id) + if reason: + reason = textwrap.shorten(reason, width=512, placeholder="...") + action = user.remove_roles(self._voice_verified_role, reason=reason) await self.apply_infraction(ctx, infraction, user, action) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 1f4a3e7f0..a6ebe2162 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -153,3 +153,20 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) self.user.remove_roles.assert_called_once_with(self.cog._voice_verified_role, reason="foobar") self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, "my_return_value") + + @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") + @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") + async def test_voice_ban_truncate_reason(self, get_active_infraction, post_infraction_mock): + """Should truncate reason for voice ban.""" + self.cog.mod_log.ignore = MagicMock() + self.cog.apply_infraction = AsyncMock() + self.user.remove_roles = MagicMock(return_value="my_return_value") + + get_active_infraction.return_value = None + post_infraction_mock.return_value = {"foo": "bar"} + + self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar" * 3000)) + self.user.remove_roles.assert_called_once_with( + self.cog._voice_verified_role, reason=textwrap.shorten("foobar" * 3000, 512, placeholder="...") + ) + self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, "my_return_value") -- cgit v1.2.3 From b8855bced0913f087d25d571fe9a5ccf7f5e1727 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:53:33 +0300 Subject: Create test for voice ban pardon when user not found --- tests/bot/exts/moderation/infraction/test_infractions.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index a6ebe2162..ae8c1d35e 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -64,6 +64,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.bot = MockBot() self.mod = MockMember(top_role=10) self.user = MockMember(top_role=1, roles=[MockRole(id=123456)]) + self.guild = MockGuild() self.ctx = MockContext(bot=self.bot, author=self.mod) self.cog = Infractions(self.bot) @@ -170,3 +171,9 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.cog._voice_verified_role, reason=textwrap.shorten("foobar" * 3000, 512, placeholder="...") ) self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, "my_return_value") + + async def test_voice_unban_user_not_found(self): + """Should include info to return dict when user was not found from guild.""" + self.guild.get_member.return_value = None + result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") + self.assertEqual(result, {"Failure": "User was not found in the guild."}) -- cgit v1.2.3 From 6e8e9fd8c3db4ac8a65bed65d2fa1ecbea1c98c5 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 15:02:27 +0300 Subject: Create base test for voice unban --- .../bot/exts/moderation/infraction/test_infractions.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index ae8c1d35e..9d4180902 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -177,3 +177,21 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.guild.get_member.return_value = None result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") self.assertEqual(result, {"Failure": "User was not found in the guild."}) + + @patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon") + @patch("bot.exts.moderation.infraction.infractions.format_user") + async def test_voice_unban_user_found(self, format_user_mock, notify_pardon_mock): + """Should add role back with ignoring, notify user and return log dictionary..""" + self.cog.mod_log.ignore = MagicMock() + self.guild.get_member.return_value = self.user + notify_pardon_mock.return_value = True + format_user_mock.return_value = "my-user" + + result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") + self.assertEqual(result, { + "Member": "my-user", + "DM": "Sent" + }) + self.cog.mod_log.ignore.assert_called_once_with(Event.member_update, self.user.id) + self.user.add_roles.assert_awaited_once_with(self.cog._voice_verified_role, reason="foobar") + notify_pardon_mock.assert_awaited_once() -- cgit v1.2.3 From 339769d8c863b192e1b298e211d1ab0261d1b26f Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 15:04:35 +0300 Subject: Create test for voice unban fail send DM --- tests/bot/exts/moderation/infraction/test_infractions.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 9d4180902..b60c203a1 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -195,3 +195,18 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.cog.mod_log.ignore.assert_called_once_with(Event.member_update, self.user.id) self.user.add_roles.assert_awaited_once_with(self.cog._voice_verified_role, reason="foobar") notify_pardon_mock.assert_awaited_once() + + @patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon") + @patch("bot.exts.moderation.infraction.infractions.format_user") + async def test_voice_unban_dm_fail(self, format_user_mock, notify_pardon_mock): + """Should add role back with ignoring, notify user and return log dictionary..""" + self.guild.get_member.return_value = self.user + notify_pardon_mock.return_value = False + format_user_mock.return_value = "my-user" + + result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") + self.assertEqual(result, { + "Member": "my-user", + "DM": "**Failed**" + }) + notify_pardon_mock.assert_awaited_once() -- cgit v1.2.3 From 7598faddd8f68e9263d1c9748becd49cb1917919 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 15:05:20 +0300 Subject: Add production voice gate role and channel to configuration --- config-default.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config-default.yml b/config-default.yml index afdb8fe95..a536a94db 100644 --- a/config-default.yml +++ b/config-default.yml @@ -169,6 +169,7 @@ guild: bot_commands: &BOT_CMD 267659945086812160 esoteric: 470884583684964352 verification: 352442727016693763 + voice_gate: 764802555427029012 # Staff admins: &ADMINS 365960823622991872 @@ -228,6 +229,7 @@ guild: unverified: 739794855945044069 verified: 352427296948486144 # @Developers on PyDis + voice_verified: 764802720779337729 # Staff admins: &ADMINS_ROLE 267628507062992896 -- cgit v1.2.3 From 0a4bed86d3826e611cd1675d54596a8dcedbe29a Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 15:08:21 +0300 Subject: Fix linting for voice gate and voice ban --- bot/exts/moderation/voice_gate.py | 7 +++---- tests/bot/exts/moderation/infraction/test_infractions.py | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index bd2afb464..8f2b51dbb 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -4,12 +4,11 @@ from datetime import datetime, timedelta import discord from dateutil import parser - from discord.ext.commands import Cog, Context, command from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Channels, Roles, VoiceGate as VoiceGateConf, MODERATION_ROLES, Event +from bot.constants import Channels, Event, MODERATION_ROLES, Roles, VoiceGate as VoiceGateConf from bot.decorators import has_no_roles, in_whitelist from bot.exts.moderation.modlog import ModLog from bot.utils.checks import InWhitelistCheckFailure @@ -55,7 +54,7 @@ class VoiceGate(Cog): log.info(f"Unable to find Metricity data about {ctx.author} ({ctx.author.id})") else: log.warning(f"Got response code {e.status} while trying to get {ctx.author.id} metricity data.") - await ctx.send(f":x: Got unexpected response from site. Please let us know about this.") + await ctx.send(":x: Got unexpected response from site. Please let us know about this.") return # Pre-parse this for better code style @@ -115,7 +114,7 @@ class VoiceGate(Cog): return # Then check is member moderator+, because we don't want to delete their messages. - if any(role.id in MODERATION_ROLES for role in message.author.roles) and is_verify_command == False: + if any(role.id in MODERATION_ROLES for role in message.author.roles) and is_verify_command is False: log.trace(f"Excluding moderator message {message.id} from deletion in #{message.channel}.") return diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index b60c203a1..caa42ba3d 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -1,6 +1,6 @@ import textwrap import unittest -from unittest.mock import AsyncMock, Mock, patch, MagicMock +from unittest.mock import AsyncMock, MagicMock, Mock, patch from bot.constants import Event from bot.exts.moderation.infraction.infractions import Infractions -- cgit v1.2.3 From c835fe8447b239871957817edf325fe1eeadfa12 Mon Sep 17 00:00:00 2001 From: spitfire-hash Date: Tue, 13 Oct 2020 12:27:27 +0400 Subject: Fixed hardcoded prefix in __main__.py --- bot/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/__main__.py b/bot/__main__.py index da042a5ed..367be1300 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -58,7 +58,7 @@ bot = Bot( redis_session=redis_session, loop=loop, command_prefix=when_mentioned_or(constants.Bot.prefix), - activity=discord.Game(name="Commands: !help"), + activity=discord.Game(name=f"Commands: {constants.Bot.prefix}help"), case_insensitive=True, max_messages=10_000, allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles), -- cgit v1.2.3 From 7b40cb697bd10f3640c9f5de3a9666d63606f68b Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 13 Oct 2020 14:41:09 +0200 Subject: Verification: implement kick note post helper --- bot/exts/moderation/verification.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index c3ad8687e..cb6dd14fb 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -11,6 +11,7 @@ from discord.ext.commands import Cog, Context, command, group, has_any_role from discord.utils import snowflake_time from bot import constants +from bot.api import ResponseCodeError from bot.bot import Bot from bot.decorators import has_no_roles, in_whitelist from bot.exts.moderation.modlog import ModLog @@ -355,6 +356,28 @@ class Verification(Cog): return n_success + async def _add_kick_note(self, member: discord.Member) -> None: + """ + Post a note regarding `member` being kicked to site. + + Allows keeping track of kicked members for auditing purposes. + """ + payload = { + "active": False, + "actor": self.bot.user.id, # Bot actions this autonomously + "expires_at": None, + "hidden": True, + "reason": f"Kicked for not having verified after {constants.Verification.kicked_after} days", + "type": "note", + "user": member.id, + } + + log.trace(f"Posting kick note: {payload!r}") + try: + await self.bot.api_client.post("bot/infractions", json=payload) + except ResponseCodeError as api_exc: + log.warning("Failed to post kick note", exc_info=api_exc) + async def _kick_members(self, members: t.Collection[discord.Member]) -> int: """ Kick `members` from the PyDis guild. -- cgit v1.2.3 From ba7429a4efb4c16c27cb7cb8c44cce4bfc13351c Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 13 Oct 2020 14:41:28 +0200 Subject: Verification: add notes to kicked users --- bot/exts/moderation/verification.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index cb6dd14fb..e92524331 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -396,6 +396,7 @@ class Verification(Cog): except discord.HTTPException as suspicious_exception: raise StopExecution(reason=suspicious_exception) await member.kick(reason=f"User has not verified in {constants.Verification.kicked_after} days") + await self._add_kick_note(member) n_kicked = await self._send_requests(members, kick_request, Limit(batch_size=2, sleep_secs=1)) self.bot.stats.incr("verification.kicked", count=n_kicked) -- cgit v1.2.3 From 85d4573f548a4a0b45a75b9c78f102dff647bcfc Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 13 Oct 2020 22:26:37 +0100 Subject: Add production debug log for native verification --- bot/exts/moderation/verification.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index c3ad8687e..8a5937c3d 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -547,6 +547,16 @@ class Verification(Cog): # video. if raw_member.get("is_pending"): await self.member_gating_cache.set(member.id, True) + + # TODO: Temporary, remove soon after asking joe. + await self.mod_log.send_log_message( + icon_url=self.bot.user.avatar_url, + colour=discord.Colour.blurple(), + title="New native gated user", + channel_id=Channels.user_log, + text=f"<@{member.id}> ({member.id})", + ) + return log.trace(f"Sending on join message to new member: {member.id}") -- cgit v1.2.3 From 0c552b0b57f87177346fe43022475800debc9e60 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 13 Oct 2020 22:28:05 +0100 Subject: Fix channel constant --- bot/exts/moderation/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index 8a5937c3d..fe7ab5c67 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -553,7 +553,7 @@ class Verification(Cog): icon_url=self.bot.user.avatar_url, colour=discord.Colour.blurple(), title="New native gated user", - channel_id=Channels.user_log, + channel_id=constants.Channels.user_log, text=f"<@{member.id}> ({member.id})", ) -- cgit v1.2.3 From aefd9a31b32dc76c37a51debd2705ce2287ec6b1 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 13 Oct 2020 22:30:18 +0100 Subject: Remove trailing whitespace from verification.py --- bot/exts/moderation/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index fe7ab5c67..d28114298 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -547,7 +547,7 @@ class Verification(Cog): # video. if raw_member.get("is_pending"): await self.member_gating_cache.set(member.id, True) - + # TODO: Temporary, remove soon after asking joe. await self.mod_log.send_log_message( icon_url=self.bot.user.avatar_url, -- cgit v1.2.3 From 1bbb8a5a9236582232472b90ccc217380fdfef6f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 14 Oct 2020 15:31:12 -0700 Subject: Utils: clarify why has_lines counts by splitting by newlines --- bot/utils/helpers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/utils/helpers.py b/bot/utils/helpers.py index b5c13ac9e..3501a3933 100644 --- a/bot/utils/helpers.py +++ b/bot/utils/helpers.py @@ -20,6 +20,7 @@ def find_nth_occurrence(string: str, substring: str, n: int) -> Optional[int]: def has_lines(string: str, count: int) -> bool: """Return True if `string` has at least `count` lines.""" + # Benchmarks show this is significantly faster than using str.count("\n") or a for loop & break. split = string.split("\n", count - 1) # Make sure the last part isn't empty, which would happen if there was a final newline. -- cgit v1.2.3 From d277ac6d3444bed43f921ee95f79255033e367ba Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 14 Oct 2020 18:53:48 -0700 Subject: Code block: fix _fix_indentation failing for line counts of 1 This could be reproduced by editing a tracked message to a single line of invalid Python that lacks any back ticks. The code was assuming there would be multiple lines because that's what the default value for the threshold is, but this threshold is not applied to edited messages. Fixes BOT-A5 --- bot/exts/info/codeblock/_parsing.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/exts/info/codeblock/_parsing.py b/bot/exts/info/codeblock/_parsing.py index e67224494..a98218dfb 100644 --- a/bot/exts/info/codeblock/_parsing.py +++ b/bot/exts/info/codeblock/_parsing.py @@ -208,6 +208,10 @@ def _fix_indentation(content: str) -> str: first_indent = _get_leading_spaces(content) first_line = lines[0][first_indent:] + # Can't assume there'll be multiple lines cause line counts of edited messages aren't checked. + if len(lines) == 1: + return first_line + second_indent = _get_leading_spaces(lines[1]) # If the first line ends with a colon, all successive lines need to be indented one -- cgit v1.2.3 From 5f4552f01506e071646c42600f30a515d77908d4 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 15 Oct 2020 13:36:38 +0200 Subject: Verification: simplify kick note reason This will make it much easier to filter out verification kicks when querying the infraction database. --- bot/exts/moderation/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index e92524331..c8e5b481f 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -367,7 +367,7 @@ class Verification(Cog): "actor": self.bot.user.id, # Bot actions this autonomously "expires_at": None, "hidden": True, - "reason": f"Kicked for not having verified after {constants.Verification.kicked_after} days", + "reason": "Verification kick", "type": "note", "user": member.id, } -- cgit v1.2.3 From c77e88c564aa83bc5544b681ed86f001d8a3b865 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 15 Oct 2020 13:36:59 -0700 Subject: Snekbox: raise paste character length It doesn't make sense for it to be at 1000 when the code gets truncated to 1000 as well. Fixes #1239 --- bot/exts/utils/snekbox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index ca6fbf5cb..59a27a2be 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -38,7 +38,7 @@ RAW_CODE_REGEX = re.compile( re.DOTALL # "." also matches newlines ) -MAX_PASTE_LEN = 1000 +MAX_PASTE_LEN = 10000 # `!eval` command whitelists EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric, Channels.code_help_voice) -- cgit v1.2.3 From 91d6f5275d2ddd005b2479ef6fb66ebc08f45c87 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Fri, 16 Oct 2020 23:54:34 +0300 Subject: display inf id actioned in mod channel --- bot/exts/moderation/infraction/_scheduler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 814b17830..dba3f1513 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -138,7 +138,7 @@ class InfractionScheduler: end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})" elif ctx.channel.id not in MODERATION_CHANNELS: log.trace( - f"Infraction #{id_} context is not in a mod channel; omitting infraction count." + f"Infraction #{id_} context is not in a mod channel; omitting infraction count and id." ) else: log.trace(f"Fetching total infraction count for {user}.") @@ -148,7 +148,7 @@ class InfractionScheduler: params={"user__id": str(user.id)} ) total = len(infractions) - end_msg = f" ({total} infraction{ngettext('', 's', total)} total)" + end_msg = f" (#{id_} ; {total} infraction{ngettext('', 's', total)} total)" # Execute the necessary actions to apply the infraction on Discord. if action_coro: -- cgit v1.2.3 From 54fb16322e49dfa60bc496ed696fefe6e69b9b9e Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 17 Oct 2020 00:08:15 +0200 Subject: Verification: avoid logging whole kick note payload Only the `member` is variable, no need to log the rest. Co-authored-by: Numerlor --- bot/exts/moderation/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index c8e5b481f..f50ceaffd 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -372,7 +372,7 @@ class Verification(Cog): "user": member.id, } - log.trace(f"Posting kick note: {payload!r}") + log.trace(f"Posting kick note for member {member} ({member.id})") try: await self.bot.api_client.post("bot/infractions", json=payload) except ResponseCodeError as api_exc: -- cgit v1.2.3 From 29d370da244801040f128ad2dca9976c0c7ad61a Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sat, 17 Oct 2020 10:30:13 +0200 Subject: Add sprinters role to filter whitelist I've added the sprinters role to the filter whitelist. This will not affect antispam and antimalware just yet, as they currently default to using the STAFF_ROLES constant. I've also kaizened the config-default.yml file by ensuring there are two linebreaks between all sections. Signed-off-by: Sebastiaan Zeeff --- bot/constants.py | 1 + config-default.yml | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index 6c8b933af..0a3e48616 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -456,6 +456,7 @@ class Roles(metaclass=YAMLGetter): owners: int partners: int python_community: int + sprinters: int team_leaders: int unverified: int verified: int # This is the Developers role on PyDis, here named verified for readability reasons. diff --git a/config-default.yml b/config-default.yml index 0e7ebf2e3..c93ab9e0c 100644 --- a/config-default.yml +++ b/config-default.yml @@ -119,6 +119,7 @@ style: voice_state_green: "https://cdn.discordapp.com/emojis/656899770094452754.png" voice_state_red: "https://cdn.discordapp.com/emojis/656899769905709076.png" + guild: id: 267624335836053506 invite: "https://discord.gg/python" @@ -225,6 +226,7 @@ guild: muted: &MUTED_ROLE 277914926603829249 partners: 323426753857191936 python_community: &PY_COMMUNITY_ROLE 458226413825294336 + sprinters: &SPRINTERS 758422482289426471 unverified: 739794855945044069 verified: 352427296948486144 # @Developers on PyDis @@ -261,6 +263,7 @@ guild: reddit: 635408384794951680 talent_pool: 569145364800602132 + filter: # What do we filter? filter_zalgo: false @@ -298,6 +301,7 @@ filter: - *OWNERS_ROLE - *HELPERS_ROLE - *PY_COMMUNITY_ROLE + - *SPRINTERS keys: @@ -326,6 +330,7 @@ urls: bot_avatar: "https://raw.githubusercontent.com/discord-python/branding/master/logos/logo_circle/logo_circle.png" github_bot_repo: "https://github.com/python-discord/bot" + anti_spam: # Clean messages that violate a rule. clean_offending: true @@ -459,10 +464,12 @@ help_channels: notify_roles: - *HELPERS_ROLE + redirect_output: delete_invocation: true delete_delay: 15 + duck_pond: threshold: 4 channel_blacklist: @@ -478,6 +485,7 @@ duck_pond: - *MOD_ANNOUNCEMENTS - *ADMIN_ANNOUNCEMENTS + python_news: mail_lists: - 'python-ideas' -- cgit v1.2.3 From f8e7b3f82244ff33cd8c8a960d7c6e734b87afd6 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sat, 17 Oct 2020 10:33:31 +0200 Subject: Use filter role whitelist for all filter features We were using different whitelists for different filters, making it slightly more difficult to maintain the role whitelists. They now all use the same list, which combines our staff roles with the Python community role and the sprinters role. Signed-off-by: Sebastiaan Zeeff --- bot/exts/filters/antimalware.py | 4 ++-- bot/exts/filters/antispam.py | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py index 7894ec48f..26f00e91f 100644 --- a/bot/exts/filters/antimalware.py +++ b/bot/exts/filters/antimalware.py @@ -6,7 +6,7 @@ from discord import Embed, Message, NotFound from discord.ext.commands import Cog from bot.bot import Bot -from bot.constants import Channels, STAFF_ROLES, URLs +from bot.constants import Channels, Filter, URLs log = logging.getLogger(__name__) @@ -61,7 +61,7 @@ class AntiMalware(Cog): # Check if user is staff, if is, return # Since we only care that roles exist to iterate over, check for the attr rather than a User/Member instance - if hasattr(message.author, "roles") and any(role.id in STAFF_ROLES for role in message.author.roles): + if hasattr(message.author, "roles") and any(role.id in Filter.role_whitelist for role in message.author.roles): return embed = Embed() diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 4964283f1..af8528a68 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -15,7 +15,6 @@ from bot.constants import ( AntiSpam as AntiSpamConfig, Channels, Colours, DEBUG_MODE, Event, Filter, Guild as GuildConfig, Icons, - STAFF_ROLES, ) from bot.converters import Duration from bot.exts.moderation.modlog import ModLog @@ -149,7 +148,7 @@ class AntiSpam(Cog): or message.guild.id != GuildConfig.id or message.author.bot or (message.channel.id in Filter.channel_whitelist and not DEBUG_MODE) - or (any(role.id in STAFF_ROLES for role in message.author.roles) and not DEBUG_MODE) + or (any(role.id in Filter.role_whitelist for role in message.author.roles) and not DEBUG_MODE) ): return -- cgit v1.2.3 From 7c5c8fa776e351263ecf6aa24f3d69570443b622 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 17 Oct 2020 16:01:28 +0300 Subject: Centralize moderation channel checks --- bot/exts/info/information.py | 9 ++------- bot/exts/moderation/infraction/_scheduler.py | 5 +++-- bot/exts/moderation/infraction/management.py | 10 ++-------- bot/utils/channel.py | 10 +++++++++- config-default.yml | 4 ++-- 5 files changed, 18 insertions(+), 20 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 0f50138e7..2d9cab94b 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -14,6 +14,7 @@ from bot import constants from bot.bot import Bot from bot.decorators import in_whitelist from bot.pagination import LinePaginator +from bot.utils.channel import is_mod_channel from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check from bot.utils.time import time_since @@ -241,14 +242,8 @@ class Information(Cog): ), ] - # Use getattr to future-proof for commands invoked via DMs. - show_verbose = ( - ctx.channel.id in constants.MODERATION_CHANNELS - or getattr(ctx.channel, "category_id", None) == constants.Categories.modmail - ) - # Show more verbose output in moderation channels for infractions and nominations - if show_verbose: + if is_mod_channel(ctx.channel): fields.append(await self.expanded_user_infraction_counts(user)) fields.append(await self.user_nomination_counts(user)) else: diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 814b17830..12d831453 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -12,11 +12,12 @@ from discord.ext.commands import Context from bot import constants from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Colours, MODERATION_CHANNELS +from bot.constants import Colours from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._utils import UserSnowflake from bot.exts.moderation.modlog import ModLog from bot.utils import messages, scheduling, time +from bot.utils.channel import is_mod_channel log = logging.getLogger(__name__) @@ -136,7 +137,7 @@ class InfractionScheduler: ) if reason: end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})" - elif ctx.channel.id not in MODERATION_CHANNELS: + elif not is_mod_channel(ctx.channel): log.trace( f"Infraction #{id_} context is not in a mod channel; omitting infraction count." ) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index cdab1a6c7..394f63da3 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -15,7 +15,7 @@ from bot.exts.moderation.infraction.infractions import Infractions from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator from bot.utils import messages, time -from bot.utils.checks import in_whitelist_check +from bot.utils.channel import is_mod_channel log = logging.getLogger(__name__) @@ -295,13 +295,7 @@ class ModManagement(commands.Cog): """Only allow moderators inside moderator channels to invoke the commands in this cog.""" checks = [ await commands.has_any_role(*constants.MODERATION_ROLES).predicate(ctx), - in_whitelist_check( - ctx, - channels=constants.MODERATION_CHANNELS, - categories=[constants.Categories.modmail], - redirect=None, - fail_silently=True, - ) + is_mod_channel(ctx.channel) ] return all(checks) diff --git a/bot/utils/channel.py b/bot/utils/channel.py index 851f9e1fe..d55faab57 100644 --- a/bot/utils/channel.py +++ b/bot/utils/channel.py @@ -2,7 +2,7 @@ import logging import discord -from bot.constants import Categories +from bot.constants import Categories, MODERATION_CHANNELS log = logging.getLogger(__name__) @@ -15,6 +15,14 @@ def is_help_channel(channel: discord.TextChannel) -> bool: return any(is_in_category(channel, category) for category in categories) +def is_mod_channel(channel: discord.TextChannel) -> bool: + """Return True if `channel` is one of the moderation channels or in one of the moderation categories.""" + log.trace(f"Checking if #{channel} is a mod channel.") + categories = (Categories.modmail, Categories.logs) + + return channel.id in MODERATION_CHANNELS or any(is_in_category(channel, category) for category in categories) + + def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: """Return True if `channel` is within a category with `category_id`.""" return getattr(channel, "category_id", None) == category_id diff --git a/config-default.yml b/config-default.yml index c93ab9e0c..12f6582ec 100644 --- a/config-default.yml +++ b/config-default.yml @@ -129,6 +129,7 @@ guild: help_in_use: 696958401460043776 help_dormant: 691405908919451718 modmail: 714494672835444826 + logs: 468520609152892958 channels: # Public announcement and news channels @@ -179,7 +180,7 @@ guild: incidents: 714214212200562749 incidents_archive: 720668923636351037 mods: &MODS 305126844661760000 - mod_alerts: &MOD_ALERTS 473092532147060736 + mod_alerts: 473092532147060736 mod_spam: &MOD_SPAM 620607373828030464 organisation: &ORGANISATION 551789653284356126 staff_lounge: &STAFF_LOUNGE 464905259261755392 @@ -202,7 +203,6 @@ guild: moderation_channels: - *ADMINS - *ADMIN_SPAM - - *MOD_ALERTS - *MODS - *MOD_SPAM -- cgit v1.2.3 From 1a330209ca81336b964dce6d6f711f6e127b5d73 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 17 Oct 2020 18:02:21 +0300 Subject: Amended to work with current tests --- bot/utils/channel.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/utils/channel.py b/bot/utils/channel.py index d55faab57..615698cab 100644 --- a/bot/utils/channel.py +++ b/bot/utils/channel.py @@ -2,7 +2,8 @@ import logging import discord -from bot.constants import Categories, MODERATION_CHANNELS +from bot import constants +from bot.constants import Categories log = logging.getLogger(__name__) @@ -20,7 +21,8 @@ def is_mod_channel(channel: discord.TextChannel) -> bool: log.trace(f"Checking if #{channel} is a mod channel.") categories = (Categories.modmail, Categories.logs) - return channel.id in MODERATION_CHANNELS or any(is_in_category(channel, category) for category in categories) + return channel.id in constants.MODERATION_CHANNELS \ + or any(is_in_category(channel, category) for category in categories) def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: -- cgit v1.2.3 From db771de1122d4f60e4531fd8538cdfb7ffeb849a Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 17 Oct 2020 19:15:30 +0300 Subject: Fixed style and linting --- bot/utils/channel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/utils/channel.py b/bot/utils/channel.py index 615698cab..487794c59 100644 --- a/bot/utils/channel.py +++ b/bot/utils/channel.py @@ -21,8 +21,8 @@ def is_mod_channel(channel: discord.TextChannel) -> bool: log.trace(f"Checking if #{channel} is a mod channel.") categories = (Categories.modmail, Categories.logs) - return channel.id in constants.MODERATION_CHANNELS \ - or any(is_in_category(channel, category) for category in categories) + return (channel.id in constants.MODERATION_CHANNELS + or any(is_in_category(channel, category) for category in categories)) def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: -- cgit v1.2.3 From 39ab2d8a2b00793ccf3ba51f21ece771624e24e0 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sat, 17 Oct 2020 19:16:34 +0200 Subject: Allow !eval in #code-help-voice-2 --- bot/constants.py | 1 + bot/exts/utils/snekbox.py | 2 +- config-default.yml | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/constants.py b/bot/constants.py index 0a3e48616..99584ab6c 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -392,6 +392,7 @@ class Channels(metaclass=YAMLGetter): bot_commands: int change_log: int code_help_voice: int + code_help_voice_2: int cooldown: int defcon: int dev_contrib: int diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index 59a27a2be..cad451571 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -41,7 +41,7 @@ RAW_CODE_REGEX = re.compile( MAX_PASTE_LEN = 10000 # `!eval` command whitelists -EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric, Channels.code_help_voice) +EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric, Channels.code_help_voice, Channels.code_help_voice_2) EVAL_CATEGORIES = (Categories.help_available, Categories.help_in_use) EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners) diff --git a/config-default.yml b/config-default.yml index c93ab9e0c..fd96ff2c6 100644 --- a/config-default.yml +++ b/config-default.yml @@ -192,6 +192,7 @@ guild: # Voice code_help_voice: 755154969761677312 + code_help_voice_2: 766330079135268884 admins_voice: &ADMINS_VOICE 500734494840717332 staff_voice: &STAFF_VOICE 412375055910043655 -- cgit v1.2.3 From e214f6e6cd0770625cd9a102b1d14a3772990534 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 18 Oct 2020 00:10:09 +0300 Subject: Added moderation categories section to config --- bot/constants.py | 4 ++++ bot/utils/channel.py | 7 ++++--- config-default.yml | 8 ++++++-- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 0a3e48616..2e6c84fc7 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -468,6 +468,7 @@ class Guild(metaclass=YAMLGetter): id: int invite: str # Discord invite, gets embedded in chat moderation_channels: List[int] + moderation_categories: List[int] moderation_roles: List[int] modlog_blacklist: List[int] reminder_whitelist: List[int] @@ -628,6 +629,9 @@ STAFF_ROLES = Guild.staff_roles # Channel combinations MODERATION_CHANNELS = Guild.moderation_channels +# Category combinations +MODERATION_CATEGORIES = Guild.moderation_categories + # Bot replies NEGATIVE_REPLIES = [ "Noooooo!!", diff --git a/bot/utils/channel.py b/bot/utils/channel.py index 487794c59..1e67d1a9b 100644 --- a/bot/utils/channel.py +++ b/bot/utils/channel.py @@ -19,10 +19,11 @@ def is_help_channel(channel: discord.TextChannel) -> bool: def is_mod_channel(channel: discord.TextChannel) -> bool: """Return True if `channel` is one of the moderation channels or in one of the moderation categories.""" log.trace(f"Checking if #{channel} is a mod channel.") - categories = (Categories.modmail, Categories.logs) - return (channel.id in constants.MODERATION_CHANNELS - or any(is_in_category(channel, category) for category in categories)) + return ( + channel.id in constants.MODERATION_CHANNELS + or any(is_in_category(channel, category) for category in constants.MODERATION_CATEGORIES) + ) def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: diff --git a/config-default.yml b/config-default.yml index 12f6582ec..baa5c783a 100644 --- a/config-default.yml +++ b/config-default.yml @@ -128,8 +128,8 @@ guild: help_available: 691405807388196926 help_in_use: 696958401460043776 help_dormant: 691405908919451718 - modmail: 714494672835444826 - logs: 468520609152892958 + modmail: &MODMAIL 714494672835444826 + logs: &LOGS 468520609152892958 channels: # Public announcement and news channels @@ -200,6 +200,10 @@ guild: big_brother_logs: &BB_LOGS 468507907357409333 talent_pool: &TALENT_POOL 534321732593647616 + moderation_categories: + - *MODMAIL + - *LOGS + moderation_channels: - *ADMINS - *ADMIN_SPAM -- cgit v1.2.3 From df6f1f39ccd43314218e84a8e242e1f4414c7ea4 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 18 Oct 2020 00:12:38 +0300 Subject: Improved logging in is_mod_channel --- bot/exts/moderation/infraction/_scheduler.py | 6 +----- bot/utils/channel.py | 19 ++++++++++++------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 12d831453..7f18017ac 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -137,11 +137,7 @@ class InfractionScheduler: ) if reason: end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})" - elif not is_mod_channel(ctx.channel): - log.trace( - f"Infraction #{id_} context is not in a mod channel; omitting infraction count." - ) - else: + elif is_mod_channel(ctx.channel): log.trace(f"Fetching total infraction count for {user}.") infractions = await self.bot.api_client.get( diff --git a/bot/utils/channel.py b/bot/utils/channel.py index 1e67d1a9b..6bf70bfde 100644 --- a/bot/utils/channel.py +++ b/bot/utils/channel.py @@ -17,13 +17,18 @@ def is_help_channel(channel: discord.TextChannel) -> bool: def is_mod_channel(channel: discord.TextChannel) -> bool: - """Return True if `channel` is one of the moderation channels or in one of the moderation categories.""" - log.trace(f"Checking if #{channel} is a mod channel.") - - return ( - channel.id in constants.MODERATION_CHANNELS - or any(is_in_category(channel, category) for category in constants.MODERATION_CATEGORIES) - ) + """True if `channel` is considered a mod channel.""" + if channel.id in constants.MODERATION_CHANNELS: + log.trace(f"Channel #{channel} is a configured mod channel") + return True + + elif any(is_in_category(channel, category) for category in constants.MODERATION_CATEGORIES): + log.trace(f"Channel #{channel} is in a configured mod category") + return True + + else: + log.trace(f"Channel #{channel} is not a mod channel") + return False def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: -- cgit v1.2.3 From bdd4cceccb7e0d8cfbe5ec60937c416ce6f0fb0e Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 09:21:56 +0300 Subject: Remove unnecessary logging about user not found Co-authored-by: Joe Banks --- bot/exts/moderation/infraction/infractions.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 0dab3a72e..93fa16242 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -443,8 +443,7 @@ class Infractions(InfractionScheduler, commands.Cog): log_text["Member"] = format_user(user) log_text["DM"] = "Sent" if notified else "**Failed**" else: - log.info(f"Failed to remove Voice Ban from user {user_id}: user not found") - log_text["Failure"] = "User was not found in the guild." + log_text["Info"] = "User was not found in the guild." return log_text -- cgit v1.2.3 From 3c2ad44a0bb0cd9cf39677da4bf8128bef387379 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 09:22:23 +0300 Subject: Fix grammar of voice ban pardoning message Co-authored-by: Joe Banks --- bot/exts/moderation/infraction/infractions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 93fa16242..a5eb720ab 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -435,8 +435,8 @@ class Infractions(InfractionScheduler, commands.Cog): # DM user about infraction expiration notified = await _utils.notify_pardon( user=user, - title="Your Voice Ban have been removed", - content="You can now speak again in voice channels.", + title="Voice ban pardoned", + content="You can now verify yourself for voice access again.", icon_url=_utils.INFRACTION_ICONS["voice_ban"][1] ) -- cgit v1.2.3 From bfd740be8cc0368df38a24906df592aa8f27c4e6 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 09:23:44 +0300 Subject: Fix name and aliases of voice ban command Co-authored-by: Joe Banks --- bot/exts/moderation/infraction/infractions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index a5eb720ab..2ccb1ca97 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -89,8 +89,8 @@ class Infractions(InfractionScheduler, commands.Cog): """ await self.apply_ban(ctx, user, reason, max(min(purge_days, 7), 0)) - @command(aliases=('vban', 'voiceban')) - async def voice_ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str]) -> None: + @command(aliases=('vban',)) + async def voiceban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str]) -> None: """Permanently ban user from using voice channels.""" await self.apply_voice_ban(ctx, user, reason) -- cgit v1.2.3 From 29e20171a73990314161e6030f7f884e0f61a122 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 09:24:27 +0300 Subject: Fix grammar of voice verifing message Co-authored-by: Joe Banks --- bot/exts/moderation/voice_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 8f2b51dbb..f487c41b2 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -93,7 +93,7 @@ class VoiceGate(Cog): self.mod_log.ignore(Event.member_update, ctx.author.id) await ctx.author.add_roles(discord.Object(Roles.voice_verified), reason="Voice Gate passed") await ctx.author.send( - ":tada: Congratulations! You are now Voice Verified and have access to PyDis Voice Channels." + ":tada: Congratulations! You have been granted permission to use voice channels in Python Discord." ) self.bot.stats.incr("voice_gate.passed") -- cgit v1.2.3 From a8b3d0c8c20364ca9737520ffe8e6a6ce649ad5a Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 09:27:39 +0300 Subject: Give user free pass when user don't have verified time in metricity --- bot/exts/moderation/voice_gate.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index f487c41b2..bdf7857f0 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -58,7 +58,10 @@ class VoiceGate(Cog): return # Pre-parse this for better code style - data["verified_at"] = parser.isoparse(data["verified_at"]) + if data["verified_at"] is not None: + data["verified_at"] = parser.isoparse(data["verified_at"]) + else: + data["verified_at"] = datetime.now() - timedelta(days=3) failed = False failed_reasons = [] -- cgit v1.2.3 From ea58222a5cfce295392bd5998b5968df89ddfeea Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 09:29:34 +0300 Subject: Don't add Voice Verified role automatically back --- bot/exts/moderation/infraction/infractions.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 2ccb1ca97..fc01eee9e 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -428,10 +428,6 @@ class Infractions(InfractionScheduler, commands.Cog): log_text = {} if user: - # Add Voice Verified role back to user. - self.mod_log.ignore(Event.member_update, user.id) - await user.add_roles(self._voice_verified_role, reason=reason) - # DM user about infraction expiration notified = await _utils.notify_pardon( user=user, -- cgit v1.2.3 From 5d732c97daccece1fe7945d92b426be76ee02ea0 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 09:31:36 +0300 Subject: Fix user not found info field test --- tests/bot/exts/moderation/infraction/test_infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index caa42ba3d..b666e1f85 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -176,7 +176,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): """Should include info to return dict when user was not found from guild.""" self.guild.get_member.return_value = None result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") - self.assertEqual(result, {"Failure": "User was not found in the guild."}) + self.assertEqual(result, {"Info": "User was not found in the guild."}) @patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon") @patch("bot.exts.moderation.infraction.infractions.format_user") -- cgit v1.2.3 From 77effb0bdf167020f4733b8d8e2bf980a4016f52 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 09:36:53 +0300 Subject: Update tests to not automatically adding back verified after vban expire --- tests/bot/exts/moderation/infraction/test_infractions.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index b666e1f85..f2617cf59 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -182,7 +182,6 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): @patch("bot.exts.moderation.infraction.infractions.format_user") async def test_voice_unban_user_found(self, format_user_mock, notify_pardon_mock): """Should add role back with ignoring, notify user and return log dictionary..""" - self.cog.mod_log.ignore = MagicMock() self.guild.get_member.return_value = self.user notify_pardon_mock.return_value = True format_user_mock.return_value = "my-user" @@ -192,8 +191,6 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): "Member": "my-user", "DM": "Sent" }) - self.cog.mod_log.ignore.assert_called_once_with(Event.member_update, self.user.id) - self.user.add_roles.assert_awaited_once_with(self.cog._voice_verified_role, reason="foobar") notify_pardon_mock.assert_awaited_once() @patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon") -- cgit v1.2.3 From b72963930a6d8d28c794c5973efbb83def39a281 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 10:04:36 +0300 Subject: Use embeds instead of normal messages and send to DM instead --- bot/exts/moderation/voice_gate.py | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index bdf7857f0..4a7c66278 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta import discord from dateutil import parser +from discord import Colour from discord.ext.commands import Cog, Context, command from bot.api import ResponseCodeError @@ -46,15 +47,28 @@ class VoiceGate(Cog): - You must have accepted our rules over a certain number of days ago - You must not be actively banned from using our voice channels """ + # Send this as first thing in order to return after sending DM + await ctx.send("Check your DMs for result.") + try: data = await self.bot.api_client.get(f"bot/users/{ctx.author.id}/metricity_data") except ResponseCodeError as e: if e.status == 404: - await ctx.send(f":x: {ctx.author.mention} Unable to find Metricity data about you.") + embed = discord.Embed( + title="Not found", + description=f"{ctx.author.mention} Unable to find Metricity data about you.", + color=Colour.red() + ) log.info(f"Unable to find Metricity data about {ctx.author} ({ctx.author.id})") else: - log.warning(f"Got response code {e.status} while trying to get {ctx.author.id} metricity data.") - await ctx.send(":x: Got unexpected response from site. Please let us know about this.") + embed = discord.Embed( + title="Unexpected response", + description="Got unexpected response from site. Please let us know about this.", + color=Colour.red() + ) + log.warning(f"Got response code {e.status} while trying to get {ctx.author.id} Metricity data.") + + await ctx.author.send(embed=embed) return # Pre-parse this for better code style @@ -85,19 +99,22 @@ class VoiceGate(Cog): else: reasons = failed_reasons[0] - await ctx.send( - FAILED_MESSAGE.format( - user=ctx.author.mention, - reasons=reasons - ) + embed = discord.Embed( + title="Voice Gate not passed", + description=FAILED_MESSAGE.format(user=ctx.author.mention, reasons=reasons), + color=Colour.red() ) + await ctx.author.send(embed=embed) return self.mod_log.ignore(Event.member_update, ctx.author.id) await ctx.author.add_roles(discord.Object(Roles.voice_verified), reason="Voice Gate passed") - await ctx.author.send( - ":tada: Congratulations! You have been granted permission to use voice channels in Python Discord." + embed = discord.Embed( + title="Congratulations", + description="You have been granted permission to use voice channels in Python Discord.", + color=Colour.green() ) + await ctx.author.send(embed=embed) self.bot.stats.incr("voice_gate.passed") @Cog.listener() -- cgit v1.2.3 From 61206175591841d7ffed7b202c1bcf81d2b9ba99 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 10:04:49 +0300 Subject: Fix voice ban command name in test --- tests/bot/exts/moderation/infraction/test_infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index f2617cf59..5dbbb8e00 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -71,7 +71,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): async def test_permanent_voice_ban(self): """Should call voice ban applying function without expiry.""" self.cog.apply_voice_ban = AsyncMock() - self.assertIsNone(await self.cog.voice_ban(self.cog, self.ctx, self.user, reason="foobar")) + self.assertIsNone(await self.cog.voiceban(self.cog, self.ctx, self.user, reason="foobar")) self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar") async def test_temporary_voice_ban(self): -- cgit v1.2.3 From 152d105715fcd9843362b09c582773191bf2af9c Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 10:14:23 +0300 Subject: Rework how voice gate do checks --- bot/exts/moderation/voice_gate.py | 42 +++++++++++++++++---------------------- 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 4a7c66278..c367510ad 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -9,20 +9,21 @@ from discord.ext.commands import Cog, Context, command from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Channels, Event, MODERATION_ROLES, Roles, VoiceGate as VoiceGateConf +from bot.constants import Channels, Event, MODERATION_ROLES, Roles, VoiceGate as GateConf from bot.decorators import has_no_roles, in_whitelist from bot.exts.moderation.modlog import ModLog from bot.utils.checks import InWhitelistCheckFailure log = logging.getLogger(__name__) -# Messages for case when user don't meet with requirements -NOT_ENOUGH_MESSAGES = f"haven't sent at least {VoiceGateConf.minimum_messages} messages" -NOT_ENOUGH_DAYS_AFTER_VERIFICATION = f"haven't been verified for at least {VoiceGateConf.minimum_days_verified} days" -VOICE_BANNED = "are voice banned" - FAILED_MESSAGE = """{user} you don't meet with our current requirements to pass Voice Gate. You {reasons}.""" +MESSAGE_FIELD_MAP = { + "verified_at": f"haven't been verified for at least {GateConf.minimum_days_verified} days", + "voice_banned": "are voice banned", + "total_messages": f"haven't sent at least {GateConf.minimum_messages} messages", +} + class VoiceGate(Cog): """Voice channels verification management.""" @@ -75,23 +76,16 @@ class VoiceGate(Cog): if data["verified_at"] is not None: data["verified_at"] = parser.isoparse(data["verified_at"]) else: - data["verified_at"] = datetime.now() - timedelta(days=3) - - failed = False - failed_reasons = [] - - if data["verified_at"] > datetime.utcnow() - timedelta(days=VoiceGateConf.minimum_days_verified): - failed_reasons.append(NOT_ENOUGH_DAYS_AFTER_VERIFICATION) - failed = True - self.bot.stats.incr("voice_gate.failed.verified_at") - if data["total_messages"] < VoiceGateConf.minimum_messages: - failed_reasons.append(NOT_ENOUGH_MESSAGES) - failed = True - self.bot.stats.incr("voice_gate.failed.total_messages") - if data["voice_banned"]: - failed_reasons.append(VOICE_BANNED) - failed = True - self.bot.stats.incr("voice_gate.failed.voice_banned") + data["verified_at"] = datetime.utcnow() - timedelta(days=3) + + checks = { + "verified_at": data["verified_at"] > datetime.utcnow() - timedelta(days=GateConf.minimum_days_verified), + "total_messages": data["total_messages"] < GateConf.minimum_messages, + "voice_banned": data["voice_banned"] + } + failed = any(checks.values()) + failed_reasons = [MESSAGE_FIELD_MAP[key] for key, value in checks.items() if value is True] + [self.bot.stats.incr(f"voice_gate.failed.{key}") for key, value in checks.items() if value is True] if failed: if len(failed_reasons) > 1: @@ -130,7 +124,7 @@ class VoiceGate(Cog): # When it's bot sent message, delete it after some time if message.author.bot: with suppress(discord.NotFound): - await message.delete(delay=VoiceGateConf.bot_message_delete_delay) + await message.delete(delay=GateConf.bot_message_delete_delay) return # Then check is member moderator+, because we don't want to delete their messages. -- cgit v1.2.3 From ee241f5c3b87cfe576351f9baeed54c4f30147db Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 10:16:28 +0300 Subject: Change message that say to user that he get response to DM --- bot/exts/moderation/voice_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index c367510ad..05a3b31de 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -49,7 +49,7 @@ class VoiceGate(Cog): - You must not be actively banned from using our voice channels """ # Send this as first thing in order to return after sending DM - await ctx.send("Check your DMs for result.") + await ctx.send("You will get response to DM.") try: data = await self.bot.api_client.get(f"bot/users/{ctx.author.id}/metricity_data") -- cgit v1.2.3 From edc099882df1cbb792e005ce36ec36d974a938ff Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 14:45:22 +0300 Subject: Fix grammar and wording of Voice Gate + Voice Ban Co-authored-by: Joe Banks --- bot/exts/moderation/infraction/infractions.py | 4 ++-- bot/exts/moderation/voice_gate.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index fc01eee9e..f2ca6a763 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -431,8 +431,8 @@ class Infractions(InfractionScheduler, commands.Cog): # DM user about infraction expiration notified = await _utils.notify_pardon( user=user, - title="Voice ban pardoned", - content="You can now verify yourself for voice access again.", + title="Voice ban ended", + content="You can verify yourself for voice access again.", icon_url=_utils.INFRACTION_ICONS["voice_ban"][1] ) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 05a3b31de..639642068 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -21,7 +21,7 @@ FAILED_MESSAGE = """{user} you don't meet with our current requirements to pass MESSAGE_FIELD_MAP = { "verified_at": f"haven't been verified for at least {GateConf.minimum_days_verified} days", "voice_banned": "are voice banned", - "total_messages": f"haven't sent at least {GateConf.minimum_messages} messages", + "total_messages": f"have sent less than {GateConf.minimum_messages} messages", } @@ -49,7 +49,7 @@ class VoiceGate(Cog): - You must not be actively banned from using our voice channels """ # Send this as first thing in order to return after sending DM - await ctx.send("You will get response to DM.") + await ctx.send(f"{ctx.author.mention}, check your DMs.") try: data = await self.bot.api_client.get(f"bot/users/{ctx.author.id}/metricity_data") @@ -57,14 +57,14 @@ class VoiceGate(Cog): if e.status == 404: embed = discord.Embed( title="Not found", - description=f"{ctx.author.mention} Unable to find Metricity data about you.", + description=f"We were unable to find user data for you. Please try again shortly, if this problem persists please contact the server staff through Modmail.", color=Colour.red() ) log.info(f"Unable to find Metricity data about {ctx.author} ({ctx.author.id})") else: embed = discord.Embed( title="Unexpected response", - description="Got unexpected response from site. Please let us know about this.", + description="We encountered an error while attempting to find data for your user. Please try again and let us know if the problem persists.", color=Colour.red() ) log.warning(f"Got response code {e.status} while trying to get {ctx.author.id} Metricity data.") @@ -104,7 +104,7 @@ class VoiceGate(Cog): self.mod_log.ignore(Event.member_update, ctx.author.id) await ctx.author.add_roles(discord.Object(Roles.voice_verified), reason="Voice Gate passed") embed = discord.Embed( - title="Congratulations", + title="Voice gate passed", description="You have been granted permission to use voice channels in Python Discord.", color=Colour.green() ) -- cgit v1.2.3 From 3c09836736711de1d25e270c643299e0290eb636 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 14:46:11 +0300 Subject: Remove checking does user have voice verified role for voice ban Co-authored-by: Joe Banks --- bot/exts/moderation/infraction/infractions.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index f2ca6a763..d41e6326e 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -359,10 +359,6 @@ class Infractions(InfractionScheduler, commands.Cog): @respect_role_hierarchy(member_arg=2) async def apply_voice_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None: """Apply a voice ban infraction with kwargs passed to `post_infraction`.""" - if constants.Roles.voice_verified not in [role.id for role in user.roles]: - await ctx.send(":x: Can't apply Voice Ban to user who have not passed the Voice Gate.") - return - if await _utils.get_active_infraction(ctx, user, "voice_ban"): return -- cgit v1.2.3 From 00b2a7551a0ff7e758d546c18849474ca8ee173c Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 15:10:20 +0300 Subject: Remove _ from infraction type when sending back result --- bot/exts/moderation/infraction/_scheduler.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index dba3f1513..bba80afaf 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -125,7 +125,7 @@ class InfractionScheduler: log.error(f"Failed to DM {user.id}: could not fetch user (status {e.status})") else: # Accordingly display whether the user was successfully notified via DM. - if await _utils.notify_infraction(user, infr_type, expiry, reason, icon): + if await _utils.notify_infraction(user, " ".join(infr_type.split("_")).title(), expiry, reason, icon): dm_result = ":incoming_envelope: " dm_log_text = "\nDM: Sent" @@ -166,7 +166,7 @@ class InfractionScheduler: log_content = ctx.author.mention log_title = "failed to apply" - log_msg = f"Failed to apply {infr_type} infraction #{id_} to {user}" + log_msg = f"Failed to apply {' '.join(infr_type.split('_'))} infraction #{id_} to {user}" if isinstance(e, discord.Forbidden): log.warning(f"{log_msg}: bot lacks permissions.") else: @@ -183,7 +183,7 @@ class InfractionScheduler: log.error(f"Deletion of {infr_type} infraction #{id_} failed with error code {e.status}.") infr_message = "" else: - infr_message = f" **{infr_type}** to {user.mention}{expiry_msg}{end_msg}" + infr_message = f" **{' '.join(infr_type.split('_'))}** to {user.mention}{expiry_msg}{end_msg}" # Send a confirmation message to the invoking context. log.trace(f"Sending infraction #{id_} confirmation message.") @@ -195,7 +195,7 @@ class InfractionScheduler: await self.mod_log.send_log_message( icon_url=icon, colour=Colours.soft_red, - title=f"Infraction {log_title}: {infr_type}", + title=f"Infraction {log_title}: {' '.join(infr_type.split('_'))}", thumbnail=user.avatar_url_as(static_format="png"), text=textwrap.dedent(f""" Member: {messages.format_user(user)} @@ -272,7 +272,7 @@ class InfractionScheduler: if send_msg: log.trace(f"Sending infraction #{id_} pardon confirmation message.") await ctx.send( - f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. " + f"{dm_emoji}{confirm_msg} infraction **{' '.join(infr_type.split('_'))}** for {user.mention}. " f"{log_text.get('Failure', '')}" ) @@ -283,7 +283,7 @@ class InfractionScheduler: await self.mod_log.send_log_message( icon_url=_utils.INFRACTION_ICONS[infr_type][1], colour=Colours.soft_green, - title=f"Infraction {log_title}: {infr_type}", + title=f"Infraction {log_title}: {' '.join(infr_type.split('_'))}", thumbnail=user.avatar_url_as(static_format="png"), text="\n".join(f"{k}: {v}" for k, v in log_text.items()), footer=footer, -- cgit v1.2.3 From 07187bd53c28f5c837f3a90eb063efea39c0cc09 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 15:11:43 +0300 Subject: Fix grammar of fail messages of Voice Gate --- bot/exts/moderation/voice_gate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 639642068..325331999 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -19,8 +19,8 @@ log = logging.getLogger(__name__) FAILED_MESSAGE = """{user} you don't meet with our current requirements to pass Voice Gate. You {reasons}.""" MESSAGE_FIELD_MAP = { - "verified_at": f"haven't been verified for at least {GateConf.minimum_days_verified} days", - "voice_banned": "are voice banned", + "verified_at": f"have been verified for less {GateConf.minimum_days_verified} days", + "voice_banned": "have an active voice ban infraction", "total_messages": f"have sent less than {GateConf.minimum_messages} messages", } -- cgit v1.2.3 From 1ce453a13b37f8b4b42ab0b87e0cac242f3b9739 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 15:21:26 +0300 Subject: Update formatting of voice gate failing embed --- bot/exts/moderation/voice_gate.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 325331999..5516675d1 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -16,7 +16,9 @@ from bot.utils.checks import InWhitelistCheckFailure log = logging.getLogger(__name__) -FAILED_MESSAGE = """{user} you don't meet with our current requirements to pass Voice Gate. You {reasons}.""" +FAILED_MESSAGE = ( + """You are not currently eligible to use voice inside Python Discord for the following reasons:\n\n{reasons}""" +) MESSAGE_FIELD_MAP = { "verified_at": f"have been verified for less {GateConf.minimum_days_verified} days", @@ -88,14 +90,9 @@ class VoiceGate(Cog): [self.bot.stats.incr(f"voice_gate.failed.{key}") for key, value in checks.items() if value is True] if failed: - if len(failed_reasons) > 1: - reasons = f"{', '.join(failed_reasons[:-1])} and {failed_reasons[-1]}" - else: - reasons = failed_reasons[0] - embed = discord.Embed( title="Voice Gate not passed", - description=FAILED_MESSAGE.format(user=ctx.author.mention, reasons=reasons), + description=FAILED_MESSAGE.format(reasons="\n".join(f'- You {reason}.' for reason in failed_reasons)), color=Colour.red() ) await ctx.author.send(embed=embed) -- cgit v1.2.3 From 082a6c0ee67ef627e987d6f9f17f1886eedb2518 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 15:22:45 +0300 Subject: Use .title() instead of .capitalize() --- bot/exts/moderation/infraction/_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index bff5fcf4c..d0dc3f0a1 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -155,7 +155,7 @@ async def notify_infraction( log.trace(f"Sending {user} a DM about their {infr_type} infraction.") text = INFRACTION_DESCRIPTION_TEMPLATE.format( - type=infr_type.capitalize(), + type=infr_type.title(), expires=expires_at or "N/A", reason=reason or "No reason provided." ) -- cgit v1.2.3 From db0251496884add226e66e0522f27521b1be1496 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 15:25:48 +0300 Subject: Fix too long lines for Voice Gate --- bot/exts/moderation/voice_gate.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 5516675d1..37db5dc87 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -59,14 +59,21 @@ class VoiceGate(Cog): if e.status == 404: embed = discord.Embed( title="Not found", - description=f"We were unable to find user data for you. Please try again shortly, if this problem persists please contact the server staff through Modmail.", + description=( + "We were unable to find user data for you. " + "Please try again shortly, " + "if this problem persists please contact the server staff through Modmail.", + ), color=Colour.red() ) log.info(f"Unable to find Metricity data about {ctx.author} ({ctx.author.id})") else: embed = discord.Embed( title="Unexpected response", - description="We encountered an error while attempting to find data for your user. Please try again and let us know if the problem persists.", + description=( + "We encountered an error while attempting to find data for your user. " + "Please try again and let us know if the problem persists." + ), color=Colour.red() ) log.warning(f"Got response code {e.status} while trying to get {ctx.author.id} Metricity data.") -- cgit v1.2.3 From e840f0f17d5f9cdfde9c610ef75224ca84fe52a4 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 15:31:00 +0300 Subject: Remove test for case when user don't have VV for voice ban --- tests/bot/exts/moderation/infraction/test_infractions.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 5dbbb8e00..bf557a484 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -86,14 +86,6 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.unvoiceban(self.cog, self.ctx, self.user)) self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_ban", self.user) - @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") - async def test_voice_ban_not_having_voice_verified_role(self, get_active_infraction_mock): - """Should send message and not apply infraction when user don't have voice verified role.""" - self.user.roles = [MockRole(id=987)] - self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) - self.ctx.send.assert_awaited_once() - get_active_infraction_mock.assert_not_awaited() - @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") async def test_voice_ban_user_have_active_infraction(self, get_active_infraction, post_infraction_mock): -- cgit v1.2.3 From f195d3d16b9ae4f66a4420f1d8bb7a004a90a7a6 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 16:36:16 +0300 Subject: Remove too much aliases for voice verify command Co-authored-by: Joe Banks --- bot/exts/moderation/voice_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 37db5dc87..7c3c6e1b0 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -38,7 +38,7 @@ class VoiceGate(Cog): """Get the currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") - @command(aliases=('voiceverify', 'vverify', 'voicev', 'vv')) + @command(aliases=('voiceverify',)) @has_no_roles(Roles.voice_verified) @in_whitelist(channels=(Channels.voice_gate,), redirect=None) async def voice_verify(self, ctx: Context, *_) -> None: -- cgit v1.2.3 From 905d90b27570831ad64d8e08cb8d0bc4d23c614e Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 16:36:55 +0300 Subject: Use bullet points instead of - for voice verify failing reasons Co-authored-by: Joe Banks --- bot/exts/moderation/voice_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 7c3c6e1b0..70583655e 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -99,7 +99,7 @@ class VoiceGate(Cog): if failed: embed = discord.Embed( title="Voice Gate not passed", - description=FAILED_MESSAGE.format(reasons="\n".join(f'- You {reason}.' for reason in failed_reasons)), + description=FAILED_MESSAGE.format(reasons="\n".join(f'• You {reason}.' for reason in failed_reasons)), color=Colour.red() ) await ctx.author.send(embed=embed) -- cgit v1.2.3 From a04575ce7ba43623d79ad4ae5611a093a7a452c5 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 16:37:25 +0300 Subject: Fix grammar of voice verification config comments Co-authored-by: Joe Banks --- config-default.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config-default.yml b/config-default.yml index 5060e48e0..c712d1eb7 100644 --- a/config-default.yml +++ b/config-default.yml @@ -511,8 +511,8 @@ verification: voice_gate: - minimum_days_verified: 3 # Days how much user have to be verified to pass Voice Gate - minimum_messages: 50 # How much messages user must have to pass Voice Gate + minimum_days_verified: 3 # How many days the user must have been verified for + minimum_messages: 50 # How many messages a user must have to be eligible for voice bot_message_delete_delay: 10 # Seconds before deleting bot's response in Voice Gate -- cgit v1.2.3 From 5154b39f8a2dab9531cdb90f27a62dfede49eed6 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 16:37:53 +0300 Subject: Fix grammar of voice unban embed description Co-authored-by: Joe Banks --- bot/exts/moderation/infraction/infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index d41e6326e..71d873667 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -428,7 +428,7 @@ class Infractions(InfractionScheduler, commands.Cog): notified = await _utils.notify_pardon( user=user, title="Voice ban ended", - content="You can verify yourself for voice access again.", + content="You have been unbanned and can verify yourself again in the server.", icon_url=_utils.INFRACTION_ICONS["voice_ban"][1] ) -- cgit v1.2.3 From 216bdb0947e9fa8b494e03f3be0d85867453f41d Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 16:38:21 +0300 Subject: Use "failed" instead "not passed" for feedback embed of voice gate fail Co-authored-by: Joe Banks --- bot/exts/moderation/voice_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 70583655e..7cadca153 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -98,7 +98,7 @@ class VoiceGate(Cog): if failed: embed = discord.Embed( - title="Voice Gate not passed", + title="Voice Gate failed", description=FAILED_MESSAGE.format(reasons="\n".join(f'• You {reason}.' for reason in failed_reasons)), color=Colour.red() ) -- cgit v1.2.3 From c49eb6597da7eb0e6973177e4e3e40730267cc11 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Mon, 19 Oct 2020 00:59:39 +1000 Subject: Send response in verification if DM fails. At the moment, the bot will attempt to DM the verification result for a member which is reliant on privacy settings allowing member DMs. This commit should add a suitable fallback of sending the response in the voice-verification channel instead. --- bot/exts/moderation/voice_gate.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 7cadca153..8b68b8e2d 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -102,7 +102,10 @@ class VoiceGate(Cog): description=FAILED_MESSAGE.format(reasons="\n".join(f'• You {reason}.' for reason in failed_reasons)), color=Colour.red() ) - await ctx.author.send(embed=embed) + try: + await ctx.author.send(embed=embed) + except discord.Forbidden: + await ctx.channel.send(ctx.author.mention, embed=embed) return self.mod_log.ignore(Event.member_update, ctx.author.id) @@ -112,7 +115,10 @@ class VoiceGate(Cog): description="You have been granted permission to use voice channels in Python Discord.", color=Colour.green() ) - await ctx.author.send(embed=embed) + try: + await ctx.author.send(embed=embed) + except discord.Forbidden: + await ctx.channel.send(ctx.author.mention, embed=embed) self.bot.stats.incr("voice_gate.passed") @Cog.listener() -- cgit v1.2.3 From 572679094288734bbbf9bac5dc59bbe1e7dad155 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Mon, 19 Oct 2020 01:15:20 +1000 Subject: Disconnect users on voiceban. On voiceban, a users effective permissions will change to not allow speaking, however this permission isn't effective until rejoining. To ensure a voiceban is immediately in effect, the user will be disconnected from any voice channels. --- bot/exts/moderation/infraction/infractions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 71d873667..746d4e154 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -371,6 +371,8 @@ class Infractions(InfractionScheduler, commands.Cog): if reason: reason = textwrap.shorten(reason, width=512, placeholder="...") + await user.move_to(None, reason="Disconnected from voice to apply voiceban.") + action = user.remove_roles(self._voice_verified_role, reason=reason) await self.apply_infraction(ctx, infraction, user, action) -- cgit v1.2.3 From 5cc01bc834e8b89b21546870989afb93b11aa554 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Mon, 19 Oct 2020 02:53:36 +1000 Subject: Ensure verified users can see verified message. When verified users get their role, they cannot see the voice-verification channel anymore, so I've added a 3 second delay for granting the role in order to ensure there's some time for them to see the response. I've also moved the DM message to only be sent if the DM message succeeds, and to not mention them in-channel to avoid distracting them from the DM notification unnecessarily, as I'm sure they'll see a near-instant response to their command usage in that channel. --- bot/exts/moderation/voice_gate.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 8b68b8e2d..f158c2906 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -1,3 +1,4 @@ +import asyncio import logging from contextlib import suppress from datetime import datetime, timedelta @@ -50,9 +51,6 @@ class VoiceGate(Cog): - You must have accepted our rules over a certain number of days ago - You must not be actively banned from using our voice channels """ - # Send this as first thing in order to return after sending DM - await ctx.send(f"{ctx.author.mention}, check your DMs.") - try: data = await self.bot.api_client.get(f"bot/users/{ctx.author.id}/metricity_data") except ResponseCodeError as e: @@ -104,12 +102,12 @@ class VoiceGate(Cog): ) try: await ctx.author.send(embed=embed) + await ctx.send(f"{ctx.author}, please check your DMs.") except discord.Forbidden: await ctx.channel.send(ctx.author.mention, embed=embed) return self.mod_log.ignore(Event.member_update, ctx.author.id) - await ctx.author.add_roles(discord.Object(Roles.voice_verified), reason="Voice Gate passed") embed = discord.Embed( title="Voice gate passed", description="You have been granted permission to use voice channels in Python Discord.", @@ -117,8 +115,14 @@ class VoiceGate(Cog): ) try: await ctx.author.send(embed=embed) + await ctx.send(f"{ctx.author}, please check your DMs.") except discord.Forbidden: await ctx.channel.send(ctx.author.mention, embed=embed) + + # wait a little bit so those who don't get DMs see the response in-channel before losing perms to see it. + await asyncio.sleep(3) + await ctx.author.add_roles(discord.Object(Roles.voice_verified), reason="Voice Gate passed") + self.bot.stats.incr("voice_gate.passed") @Cog.listener() -- cgit v1.2.3 From 7b8f85752098e5bfd77e033d8eddad3b8e5f2b40 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Mon, 19 Oct 2020 02:59:32 +1000 Subject: Address a grammar error in failed reasons. An overlooked grammatical error occurred in exactly 1 (one) of the possible failure reasons when being verified for the voice gate system. This was unacceptable to the masses, so a swift correction has been added to address it, adding 1 (one) additional word to the listed reason. --- bot/exts/moderation/voice_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index f158c2906..ee3ac4003 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -22,7 +22,7 @@ FAILED_MESSAGE = ( ) MESSAGE_FIELD_MAP = { - "verified_at": f"have been verified for less {GateConf.minimum_days_verified} days", + "verified_at": f"have been verified for less than {GateConf.minimum_days_verified} days", "voice_banned": "have an active voice ban infraction", "total_messages": f"have sent less than {GateConf.minimum_messages} messages", } -- cgit v1.2.3 From 0bf48fce994e51428d679605281a879d2abc9905 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Mon, 19 Oct 2020 03:13:12 +1000 Subject: Instruct to reconnect to voice channel if connected on verification. If a user is already connected to a voice channel at the time of getting verified through voice gate, they won't have their permissions actually apply to their current session. This change adds information on verifying so they know they must reconnect to have the changes apply. --- bot/exts/moderation/voice_gate.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index ee3ac4003..c2743e136 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -113,6 +113,10 @@ class VoiceGate(Cog): description="You have been granted permission to use voice channels in Python Discord.", color=Colour.green() ) + + if ctx.author.voice: + embed.description += "\n\nPlease reconnect to your voice channel to be granted your new permissions." + try: await ctx.author.send(embed=embed) await ctx.send(f"{ctx.author}, please check your DMs.") -- cgit v1.2.3 From 44ffb80ae132472c377a280218f839e4f9b21e47 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 19 Oct 2020 00:36:45 +0200 Subject: Set logging level for async-rediscache to warning --- bot/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/__init__.py b/bot/__init__.py index 3ee70c4e9..ce5b21fbf 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -64,6 +64,7 @@ coloredlogs.install(logger=root_log, stream=sys.stdout) logging.getLogger("discord").setLevel(logging.WARNING) logging.getLogger("websockets").setLevel(logging.WARNING) logging.getLogger("chardet").setLevel(logging.WARNING) +logging.getLogger("async_rediscache").setLevel(logging.WARNING) logging.getLogger(__name__) -- cgit v1.2.3 From dc2797bc7d24d80c08c559bc381c465db1312143 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 18 Oct 2020 18:12:29 -0700 Subject: Silence: rename function to reduce ambiguity --- bot/exts/moderation/silence.py | 4 ++-- tests/bot/exts/moderation/test_silence.py | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index cfdefe103..3bbf8d21a 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -107,7 +107,7 @@ class Silence(commands.Cog): channel_info = f"#{ctx.channel} ({ctx.channel.id})" log.debug(f"{ctx.author} is silencing channel {channel_info}.") - if not await self._silence_overwrites(ctx.channel): + if not await self._set_silence_overwrites(ctx.channel): log.info(f"Tried to silence channel {channel_info} but the channel was already silenced.") await ctx.send(MSG_SILENCE_FAIL) return @@ -144,7 +144,7 @@ class Silence(commands.Cog): else: await channel.send(MSG_UNSILENCE_SUCCESS) - async def _silence_overwrites(self, channel: TextChannel) -> bool: + async def _set_silence_overwrites(self, channel: TextChannel) -> bool: """Set silence permission overwrites for `channel` and return True if successful.""" overwrite = channel.overwrites_for(self._verified_role) prev_overwrites = dict(send_messages=overwrite.send_messages, add_reactions=overwrite.add_reactions) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 6d5ffa7e8..6b67a21a0 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -274,7 +274,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): ) for duration, message, was_silenced in test_cases: ctx = MockContext() - with mock.patch.object(self.cog, "_silence_overwrites", return_value=was_silenced): + with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=was_silenced): with self.subTest(was_silenced=was_silenced, message=message, duration=duration): await self.cog.silence.callback(self.cog, ctx, duration) ctx.send.assert_called_once_with(message) @@ -293,12 +293,12 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): channel = MockTextChannel() channel.overwrites_for.return_value = overwrite - self.assertFalse(await self.cog._silence_overwrites(channel)) + self.assertFalse(await self.cog._set_silence_overwrites(channel)) channel.set_permissions.assert_not_called() async def test_silenced_channel(self): """Channel had `send_message` and `add_reactions` permissions revoked for verified role.""" - self.assertTrue(await self.cog._silence_overwrites(self.channel)) + self.assertTrue(await self.cog._set_silence_overwrites(self.channel)) self.assertFalse(self.overwrite.send_messages) self.assertFalse(self.overwrite.add_reactions) self.channel.set_permissions.assert_awaited_once_with( @@ -309,7 +309,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_preserved_other_overwrites(self): """Channel's other unrelated overwrites were not changed.""" prev_overwrite_dict = dict(self.overwrite) - await self.cog._silence_overwrites(self.channel) + await self.cog._set_silence_overwrites(self.channel) new_overwrite_dict = dict(self.overwrite) # Remove 'send_messages' & 'add_reactions' keys because they were changed by the method. @@ -322,26 +322,26 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_temp_not_added_to_notifier(self): """Channel was not added to notifier if a duration was set for the silence.""" - with mock.patch.object(self.cog, "_silence_overwrites", return_value=True): + with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=True): await self.cog.silence.callback(self.cog, MockContext(), 15) self.cog.notifier.add_channel.assert_not_called() async def test_indefinite_added_to_notifier(self): """Channel was added to notifier if a duration was not set for the silence.""" - with mock.patch.object(self.cog, "_silence_overwrites", return_value=True): + with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=True): await self.cog.silence.callback(self.cog, MockContext(), None) self.cog.notifier.add_channel.assert_called_once() async def test_silenced_not_added_to_notifier(self): """Channel was not added to the notifier if it was already silenced.""" - with mock.patch.object(self.cog, "_silence_overwrites", return_value=False): + with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=False): await self.cog.silence.callback(self.cog, MockContext(), 15) self.cog.notifier.add_channel.assert_not_called() async def test_cached_previous_overwrites(self): """Channel's previous overwrites were cached.""" overwrite_json = '{"send_messages": true, "add_reactions": false}' - await self.cog._silence_overwrites(self.channel) + await self.cog._set_silence_overwrites(self.channel) self.cog.previous_overwrites.set.assert_called_once_with(self.channel.id, overwrite_json) @autospec(silence, "datetime") -- cgit v1.2.3 From a03fe441e1082c93d268a7e6673ae9f0b3feba34 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 18 Oct 2020 18:31:30 -0700 Subject: Silence: add locks to commands --- bot/exts/moderation/silence.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 3bbf8d21a..e6712b3b6 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -2,6 +2,7 @@ import json import logging from contextlib import suppress from datetime import datetime, timedelta, timezone +from operator import attrgetter from typing import Optional from async_rediscache import RedisCache @@ -12,10 +13,13 @@ from discord.ext.commands import Context from bot.bot import Bot from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles from bot.converters import HushDurationConverter +from bot.utils.lock import LockedResourceError, lock_arg from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) +LOCK_NAMESPACE = "silence" + MSG_SILENCE_FAIL = f"{Emojis.cross_mark} current channel is already silenced." MSG_SILENCE_PERMANENT = f"{Emojis.check_mark} silenced current channel indefinitely." MSG_SILENCE_SUCCESS = f"{Emojis.check_mark} silenced current channel for {{duration}} minute(s)." @@ -95,6 +99,7 @@ class Silence(commands.Cog): await self._reschedule() @commands.command(aliases=("hush",)) + @lock_arg(LOCK_NAMESPACE, "ctx", attrgetter("channel"), raise_error=True) async def silence(self, ctx: Context, duration: HushDurationConverter = 10) -> None: """ Silence the current channel for `duration` minutes or `forever`. @@ -133,6 +138,7 @@ class Silence(commands.Cog): log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") await self._unsilence_wrapper(ctx.channel) + @lock_arg(LOCK_NAMESPACE, "channel", raise_error=True) async def _unsilence_wrapper(self, channel: TextChannel) -> None: """Unsilence `channel` and send a success/failure message.""" if not await self._unsilence(channel): @@ -222,7 +228,9 @@ class Silence(commands.Cog): dt = datetime.fromtimestamp(timestamp, tz=timezone.utc) delta = (dt - datetime.now(tz=timezone.utc)).total_seconds() if delta <= 0: - await self._unsilence_wrapper(channel) + # Suppress the error since it's not being invoked by a user via the command. + with suppress(LockedResourceError): + await self._unsilence_wrapper(channel) else: log.info(f"Rescheduling silence for #{channel} ({channel.id}).") self.scheduler.schedule_later(delta, channel_id, self._unsilence_wrapper(channel)) -- cgit v1.2.3 From 75efbf81cf403d1f03f9d3147a6493f08081f55b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 18 Oct 2020 18:32:32 -0700 Subject: Reminders: rename namespace constant It's better to have a self-documenting name than a comment, which, by the way, was using the old name for the decorator. --- bot/exts/utils/reminders.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index bf4e24661..3113a1149 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -23,7 +23,7 @@ from bot.utils.time import humanize_delta log = logging.getLogger(__name__) -NAMESPACE = "reminder" # Used for the mutually_exclusive decorator; constant to prevent typos +LOCK_NAMESPACE = "reminder" WHITELISTED_CHANNELS = Guild.reminder_whitelist MAXIMUM_REMINDERS = 5 @@ -170,7 +170,7 @@ class Reminders(Cog): log.trace(f"Scheduling new task #{reminder['id']}") self.schedule_reminder(reminder) - @lock_arg(NAMESPACE, "reminder", itemgetter("id"), raise_error=True) + @lock_arg(LOCK_NAMESPACE, "reminder", itemgetter("id"), raise_error=True) async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: """Send the reminder.""" is_valid, user, channel = self.ensure_valid_reminder(reminder) @@ -378,7 +378,7 @@ class Reminders(Cog): mention_ids = [mention.id for mention in mentions] await self.edit_reminder(ctx, id_, {"mentions": mention_ids}) - @lock_arg(NAMESPACE, "id_", raise_error=True) + @lock_arg(LOCK_NAMESPACE, "id_", raise_error=True) async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: """Edits a reminder with the given payload, then sends a confirmation message.""" if not await self._can_modify(ctx, id_): @@ -398,7 +398,7 @@ class Reminders(Cog): await self._reschedule_reminder(reminder) @remind_group.command("delete", aliases=("remove", "cancel")) - @lock_arg(NAMESPACE, "id_", raise_error=True) + @lock_arg(LOCK_NAMESPACE, "id_", raise_error=True) async def delete_reminder(self, ctx: Context, id_: int) -> None: """Delete one of your active reminders.""" if not await self._can_modify(ctx, id_): -- cgit v1.2.3 From 4d1c20461d84613d6102b213fcc333ef12f168e7 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 21 Oct 2020 00:35:50 +0200 Subject: Remove unnecessary getLogger call --- bot/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/__init__.py b/bot/__init__.py index ce5b21fbf..4fce04532 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -65,7 +65,6 @@ logging.getLogger("discord").setLevel(logging.WARNING) logging.getLogger("websockets").setLevel(logging.WARNING) logging.getLogger("chardet").setLevel(logging.WARNING) logging.getLogger("async_rediscache").setLevel(logging.WARNING) -logging.getLogger(__name__) # On Windows, the selector event loop is required for aiodns. -- cgit v1.2.3 From 5be3f87751d4bf87c848f278050867ba45c442ec Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Wed, 21 Oct 2020 12:42:08 +0100 Subject: Relay python-dev to mailing lists channel --- config-default.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/config-default.yml b/config-default.yml index 71d4419a7..98c5ff42c 100644 --- a/config-default.yml +++ b/config-default.yml @@ -498,6 +498,7 @@ python_news: - 'python-ideas' - 'python-announce-list' - 'pypi-announce' + - 'python-dev' channel: *PYNEWS_CHANNEL webhook: *PYNEWS_WEBHOOK -- cgit v1.2.3 From 50324b3ec0e0285300e4f4cf389fd93c4801f1ec Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 21 Oct 2020 09:46:33 -0700 Subject: Silence tests: update docstrings in notifier tests --- tests/bot/exts/moderation/test_silence.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 6b67a21a0..104293d8e 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -43,7 +43,7 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): self.notifier.start = self.notifier_start_mock = Mock() def test_add_channel_adds_channel(self): - """Channel in FirstHash with current loop is added to internal set.""" + """Channel is added to `_silenced_channels` with the current loop.""" channel = Mock() with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels: self.notifier.add_channel(channel) @@ -61,7 +61,7 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): self.notifier_start_mock.assert_not_called() def test_remove_channel_removes_channel(self): - """Channel in FirstHash is removed from `_silenced_channels`.""" + """Channel is removed from `_silenced_channels`.""" channel = Mock() with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels: self.notifier.remove_channel(channel) -- cgit v1.2.3 From d2ba37a0f5d20aa0e0100b91fae97ef0f0d9714b Mon Sep 17 00:00:00 2001 From: mbaruh Date: Thu, 22 Oct 2020 02:39:55 +0300 Subject: Add handling of off-server users to user command --- bot/exts/info/information.py | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 2d9cab94b..60c88f375 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -6,7 +6,7 @@ from collections import Counter, defaultdict from string import Template from typing import Any, Mapping, Optional, Tuple, Union -from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, utils +from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, User, utils from discord.abc import GuildChannel from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role @@ -192,7 +192,7 @@ class Information(Cog): await ctx.send(embed=embed) @command(name="user", aliases=["user_info", "member", "member_info"]) - async def user_info(self, ctx: Context, user: Member = None) -> None: + async def user_info(self, ctx: Context, user: Union[Member, User] = None) -> None: """Returns info about a user.""" if user is None: user = ctx.author @@ -207,12 +207,14 @@ class Information(Cog): embed = await self.create_user_embed(ctx, user) await ctx.send(embed=embed) - async def create_user_embed(self, ctx: Context, user: Member) -> Embed: + async def create_user_embed(self, ctx: Context, user: Union[User, Member]) -> Embed: """Creates an embed containing information on the `user`.""" + on_server = bool(ctx.guild.get_member(user.id)) + created = time_since(user.created_at, max_units=3) name = str(user) - if user.nick: + if on_server and user.nick: name = f"{user.nick} ({name})" badges = [] @@ -221,8 +223,16 @@ class Information(Cog): if is_set and (emoji := getattr(constants.Emojis, f"badge_{badge}", None)): badges.append(emoji) - joined = time_since(user.joined_at, max_units=3) - roles = ", ".join(role.mention for role in user.roles[1:]) + if on_server: + joined = time_since(user.joined_at, max_units=3) + roles = ", ".join(role.mention for role in user.roles[1:]) + membership = textwrap.dedent(f""" + Joined: {joined} + Roles: {roles or None} + """).strip() + else: + roles = None + membership = "The user is not a member of the server" fields = [ ( @@ -235,10 +245,7 @@ class Information(Cog): ), ( "Member information", - textwrap.dedent(f""" - Joined: {joined} - Roles: {roles or None} - """).strip() + membership ), ] @@ -263,13 +270,13 @@ class Information(Cog): return embed - async def basic_user_infraction_counts(self, member: Member) -> Tuple[str, str]: + async def basic_user_infraction_counts(self, user: Union[User, Member]) -> Tuple[str, str]: """Gets the total and active infraction counts for the given `member`.""" infractions = await self.bot.api_client.get( 'bot/infractions', params={ 'hidden': 'False', - 'user__id': str(member.id) + 'user__id': str(user.id) } ) @@ -280,7 +287,7 @@ class Information(Cog): return "Infractions", infraction_output - async def expanded_user_infraction_counts(self, member: Member) -> Tuple[str, str]: + async def expanded_user_infraction_counts(self, user: Union[User, Member]) -> Tuple[str, str]: """ Gets expanded infraction counts for the given `member`. @@ -290,7 +297,7 @@ class Information(Cog): infractions = await self.bot.api_client.get( 'bot/infractions', params={ - 'user__id': str(member.id) + 'user__id': str(user.id) } ) @@ -321,12 +328,12 @@ class Information(Cog): return "Infractions", "\n".join(infraction_output) - async def user_nomination_counts(self, member: Member) -> Tuple[str, str]: + async def user_nomination_counts(self, user: Union[User, Member]) -> Tuple[str, str]: """Gets the active and historical nomination counts for the given `member`.""" nominations = await self.bot.api_client.get( 'bot/nominations', params={ - 'user__id': str(member.id) + 'user__id': str(user.id) } ) -- cgit v1.2.3 From fe7a13c07844636c4182d6b32b0fda2f9e03c1a0 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Thu, 22 Oct 2020 03:52:54 +0300 Subject: Use FetchedMember for user annotation --- bot/exts/info/information.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 60c88f375..5aaf85e5a 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -6,12 +6,13 @@ from collections import Counter, defaultdict from string import Template from typing import Any, Mapping, Optional, Tuple, Union -from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, User, utils +from discord import ChannelType, Colour, Embed, Guild, Message, Role, Status, utils from discord.abc import GuildChannel from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role from bot import constants from bot.bot import Bot +from bot.converters import FetchedMember from bot.decorators import in_whitelist from bot.pagination import LinePaginator from bot.utils.channel import is_mod_channel @@ -192,7 +193,7 @@ class Information(Cog): await ctx.send(embed=embed) @command(name="user", aliases=["user_info", "member", "member_info"]) - async def user_info(self, ctx: Context, user: Union[Member, User] = None) -> None: + async def user_info(self, ctx: Context, user: FetchedMember = None) -> None: """Returns info about a user.""" if user is None: user = ctx.author @@ -207,7 +208,7 @@ class Information(Cog): embed = await self.create_user_embed(ctx, user) await ctx.send(embed=embed) - async def create_user_embed(self, ctx: Context, user: Union[User, Member]) -> Embed: + async def create_user_embed(self, ctx: Context, user: FetchedMember) -> Embed: """Creates an embed containing information on the `user`.""" on_server = bool(ctx.guild.get_member(user.id)) @@ -270,7 +271,7 @@ class Information(Cog): return embed - async def basic_user_infraction_counts(self, user: Union[User, Member]) -> Tuple[str, str]: + async def basic_user_infraction_counts(self, user: FetchedMember) -> Tuple[str, str]: """Gets the total and active infraction counts for the given `member`.""" infractions = await self.bot.api_client.get( 'bot/infractions', @@ -287,7 +288,7 @@ class Information(Cog): return "Infractions", infraction_output - async def expanded_user_infraction_counts(self, user: Union[User, Member]) -> Tuple[str, str]: + async def expanded_user_infraction_counts(self, user: FetchedMember) -> Tuple[str, str]: """ Gets expanded infraction counts for the given `member`. @@ -328,7 +329,7 @@ class Information(Cog): return "Infractions", "\n".join(infraction_output) - async def user_nomination_counts(self, user: Union[User, Member]) -> Tuple[str, str]: + async def user_nomination_counts(self, user: FetchedMember) -> Tuple[str, str]: """Gets the active and historical nomination counts for the given `member`.""" nominations = await self.bot.api_client.get( 'bot/nominations', -- cgit v1.2.3 From 8f575bd268675bfb7979ebd0aa6480ee195c18ab Mon Sep 17 00:00:00 2001 From: Dennis Pham Date: Thu, 22 Oct 2020 10:51:15 -0400 Subject: Update Python Discord badge to 100k members. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cae7c3454..b37ece296 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Python Utility Bot -[![Discord](https://img.shields.io/static/v1?label=Python%20Discord&logo=discord&message=%3E60k%20members&color=%237289DA&logoColor=white)](https://discord.gg/2B963hn) +[![Discord](https://img.shields.io/static/v1?label=Python%20Discord&logo=discord&message=%3E100k%20members&color=%237289DA&logoColor=white)](https://discord.gg/2B963hn) [![Build Status](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot?branchName=master)](https://dev.azure.com/python-discord/Python%20Discord/_build/latest?definitionId=1&branchName=master) [![Tests](https://img.shields.io/azure-devops/tests/python-discord/Python%20Discord/1?compact_message)](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot?branchName=master) [![Coverage](https://img.shields.io/azure-devops/coverage/python-discord/Python%20Discord/1/master)](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot?branchName=master) -- cgit v1.2.3 From aa06b099fc66ad87cbdb75d8f096ca189fd2a57f Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Thu, 22 Oct 2020 09:53:07 -0500 Subject: Added voice_chat to eval list Signed-off-by: Daniel Brown --- bot/constants.py | 1 + bot/exts/utils/snekbox.py | 8 +++++++- config-default.yml | 1 + 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/bot/constants.py b/bot/constants.py index b615dcd19..1bd6ef5e0 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -425,6 +425,7 @@ class Channels(metaclass=YAMLGetter): user_event_announcements: int user_log: int verification: int + voice_chat: int voice_gate: int voice_log: int diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index cad451571..8befb6fde 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -41,7 +41,13 @@ RAW_CODE_REGEX = re.compile( MAX_PASTE_LEN = 10000 # `!eval` command whitelists -EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric, Channels.code_help_voice, Channels.code_help_voice_2) +EVAL_CHANNELS = ( + Channels.bot_commands, + Channels.esoteric, + Channels.code_help_voice, + Channels.code_help_voice_2, + Channels.voice_chat +) EVAL_CATEGORIES = (Categories.help_available, Categories.help_in_use) EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners) diff --git a/config-default.yml b/config-default.yml index 98c5ff42c..3eac3d171 100644 --- a/config-default.yml +++ b/config-default.yml @@ -195,6 +195,7 @@ guild: # Voice code_help_voice: 755154969761677312 code_help_voice_2: 766330079135268884 + voice-chat: 412357430186344448 admins_voice: &ADMINS_VOICE 500734494840717332 staff_voice: &STAFF_VOICE 412375055910043655 -- cgit v1.2.3 From 0a02ba8d61fa4cee2e38baf3c34d8cb76d7530b0 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Thu, 22 Oct 2020 10:14:47 -0500 Subject: Update config-default.yml Changing a hyphen to an underscore in the config Co-authored-by: kwzrd <44734341+kwzrd@users.noreply.github.com> --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index 3eac3d171..3f4352153 100644 --- a/config-default.yml +++ b/config-default.yml @@ -195,7 +195,7 @@ guild: # Voice code_help_voice: 755154969761677312 code_help_voice_2: 766330079135268884 - voice-chat: 412357430186344448 + voice_chat: 412357430186344448 admins_voice: &ADMINS_VOICE 500734494840717332 staff_voice: &STAFF_VOICE 412375055910043655 -- cgit v1.2.3 From eb9777bb0563e02d46d1b6597a39e6e3a9131334 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Thu, 22 Oct 2020 10:18:36 -0500 Subject: Swapped individual channel ids for category id. Signed-off-by: Daniel Brown --- bot/constants.py | 1 + bot/exts/utils/snekbox.py | 10 ++-------- config-default.yml | 1 + 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 1bd6ef5e0..23d5b4304 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -377,6 +377,7 @@ class Categories(metaclass=YAMLGetter): help_in_use: int help_dormant: int modmail: int + voice: int class Channels(metaclass=YAMLGetter): diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index 8befb6fde..213d57365 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -41,14 +41,8 @@ RAW_CODE_REGEX = re.compile( MAX_PASTE_LEN = 10000 # `!eval` command whitelists -EVAL_CHANNELS = ( - Channels.bot_commands, - Channels.esoteric, - Channels.code_help_voice, - Channels.code_help_voice_2, - Channels.voice_chat -) -EVAL_CATEGORIES = (Categories.help_available, Categories.help_in_use) +EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric) +EVAL_CATEGORIES = (Categories.help_available, Categories.help_in_use, Categories.voice) EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners) SIGKILL = 9 diff --git a/config-default.yml b/config-default.yml index 3f4352153..071f6e1ec 100644 --- a/config-default.yml +++ b/config-default.yml @@ -130,6 +130,7 @@ guild: help_dormant: 691405908919451718 modmail: &MODMAIL 714494672835444826 logs: &LOGS 468520609152892958 + voice: 356013253765234688 channels: # Public announcement and news channels -- cgit v1.2.3