From ab0d44b8e013694c2e92af51c6fdb8ed9239c331 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Wed, 25 Dec 2019 18:20:16 +0100 Subject: Hardcode SIGKILL value It allows the cog to also work on Windows, because of Signals.SIGKILL not being defined on this platform --- 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 da33e27b2..e9e5465ad 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -36,6 +36,8 @@ RAW_CODE_REGEX = re.compile( MAX_PASTE_LEN = 1000 EVAL_ROLES = (Roles.helpers, Roles.moderator, Roles.admin, Roles.owner, Roles.rockstars, Roles.partners) +SIGKILL = 9 + class Snekbox(Cog): """Safe evaluation of Python code using Snekbox.""" @@ -101,7 +103,7 @@ class Snekbox(Cog): if returncode is None: msg = "Your eval job has failed" error = stdout.strip() - elif returncode == 128 + Signals.SIGKILL: + elif returncode == 128 + SIGKILL: msg = "Your eval job timed out or ran out of memory" elif returncode == 255: msg = "Your eval job has failed" -- cgit v1.2.3 From e53492ed9485e169b2fa471635c2ea624ec1d532 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Thu, 26 Dec 2019 11:17:48 +0100 Subject: Correct eval output to include the 11th line --- 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 e9e5465ad..00b8618e2 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -154,7 +154,7 @@ class Snekbox(Cog): lines = output.count("\n") if lines > 0: - output = output.split("\n")[:10] # Only first 10 cause the rest is truncated anyway + output = output.split("\n")[:11] # Only first 11 cause the rest is truncated anyway output = (f"{i:03d} | {line}" for i, line in enumerate(output, 1)) output = "\n".join(output) -- cgit v1.2.3 From b5730e0b07a4eb886710d71648ba4c0ffb4ebf79 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 28 Jan 2020 14:50:58 +0000 Subject: Don't strip whitespaces during snekbox formatting It could lead to a misleading result if it is stripped. --- bot/cogs/snekbox.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 00b8618e2..81951efd3 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -137,7 +137,7 @@ class Snekbox(Cog): """ log.trace("Formatting output...") - output = output.strip(" \n") + output = output.rstrip("\n") original_output = output # To be uploaded to a pasting service if needed paste_link = None @@ -171,7 +171,6 @@ class Snekbox(Cog): if truncated: paste_link = await self.upload_output(original_output) - output = output.strip() if not output: output = "[No output]" -- cgit v1.2.3 From 6991e5fab13893c05d6f220e71f6ffc71509c1aa Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 29 Jan 2020 19:19:58 +0000 Subject: Re-eval snippet with emoji reaction If the eval message is edited after less than 10 seconds, an emoji is added to the message, and if the user adds the same, the snippet is re-evaluated. This make easier to correct snipper mistakes. --- bot/cogs/snekbox.py | 69 +++++++++++++++++++++++++++++++++++------------------ 1 file changed, 46 insertions(+), 23 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 81951efd3..1688c0278 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -1,3 +1,4 @@ +import asyncio import datetime import logging import re @@ -200,32 +201,54 @@ class Snekbox(Cog): log.info(f"Received code from {ctx.author} for evaluation:\n{code}") - self.jobs[ctx.author.id] = datetime.datetime.now() - code = self.prepare_input(code) + while True: + self.jobs[ctx.author.id] = datetime.datetime.now() + code = self.prepare_input(code) - try: - 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```py\n{output}\n```" - if paste_link: - msg = f"{msg}\nFull output: {paste_link}" - - response = await ctx.send(msg) - self.bot.loop.create_task( - wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot) + try: + 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```py\n{output}\n```" + if paste_link: + msg = f"{msg}\nFull output: {paste_link}" + + 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']}") + finally: + del self.jobs[ctx.author.id] + + try: + _, new_message = await self.bot.wait_for( + 'message_edit', + check=lambda o, n: n.id == ctx.message.id and o.content != n.content, + timeout=10 + ) + await ctx.message.add_reaction('🔁') + await self.bot.wait_for( + 'reaction_add', + check=lambda r, u: r.message.id == ctx.message.id and u.id == ctx.author.id and str(r) == '🔁', + timeout=10 ) - log.info(f"{ctx.author}'s job had a return code of {results['returncode']}") - finally: - del self.jobs[ctx.author.id] + log.info(f"Re-evaluating message {ctx.message.id}") + code = new_message.content.split(' ', maxsplit=1)[1] + await ctx.message.clear_reactions() + await response.delete() + except asyncio.TimeoutError: + await ctx.message.clear_reactions() + return def setup(bot: Bot) -> None: -- cgit v1.2.3 From 032e1b80934194b85c43d67f3a26cf51b972696d Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Sun, 9 Feb 2020 17:03:35 +0100 Subject: Use actual functions instead of lambdas for bot.wait_for The use of lambdas made the functions hard to test, this new format allows us to easily test those functions and document them. --- bot/cogs/snekbox.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 1688c0278..3fc8d9937 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -3,9 +3,11 @@ import datetime import logging import re import textwrap +from functools import partial from signal import Signals from typing import Optional, Tuple +from discord import Message, Reaction, User from discord.ext.commands import Cog, Context, command, guild_only from bot.bot import Bot @@ -232,13 +234,13 @@ class Snekbox(Cog): try: _, new_message = await self.bot.wait_for( 'message_edit', - check=lambda o, n: n.id == ctx.message.id and o.content != n.content, + check=partial(predicate_eval_message_edit, ctx), timeout=10 ) await ctx.message.add_reaction('🔁') await self.bot.wait_for( 'reaction_add', - check=lambda r, u: r.message.id == ctx.message.id and u.id == ctx.author.id and str(r) == '🔁', + check=partial(predicate_eval_emoji_reaction, ctx), timeout=10 ) @@ -251,6 +253,16 @@ class Snekbox(Cog): return +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 🔁 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) == '🔁' + + def setup(bot: Bot) -> None: """Load the Snekbox cog.""" bot.add_cog(Snekbox(bot)) -- cgit v1.2.3 From adf63e65a0b861cb02a6e8cc1e5b2c2c09e57726 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Sun, 9 Feb 2020 17:05:09 +0100 Subject: Create an AsyncContextManagerMock mock for testing asynchronous context managers It can be used to test aiohttp request functions, since they are async context managers --- tests/helpers.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/helpers.py b/tests/helpers.py index 5df796c23..6aee8623f 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -127,6 +127,18 @@ class AsyncMock(CustomMockMixin, unittest.mock.MagicMock): return super().__call__(*args, **kwargs) +class AsyncContextManagerMock(unittest.mock.MagicMock): + def __init__(self, return_value: Any): + super().__init__() + self._return_value = return_value + + async def __aenter__(self): + return self._return_value + + async def __aexit__(self, *args): + pass + + class AsyncIteratorMock: """ A class to mock asynchronous iterators. -- cgit v1.2.3 From 8c8f8dd50f376c3398c04e7bbe76b5028b69ff83 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Sun, 9 Feb 2020 17:11:47 +0100 Subject: Write tests for bot/cogs/test_snekbox.py --- tests/bot/cogs/test_snekbox.py | 363 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 tests/bot/cogs/test_snekbox.py diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py new file mode 100644 index 000000000..293efed0f --- /dev/null +++ b/tests/bot/cogs/test_snekbox.py @@ -0,0 +1,363 @@ +import asyncio +import logging +import unittest +from unittest.mock import MagicMock, Mock, call, patch + +from bot.cogs import snekbox +from bot.cogs.snekbox import Snekbox +from bot.constants import URLs +from tests.helpers import ( + AsyncContextManagerMock, AsyncMock, MockBot, MockContext, MockMessage, MockReaction, MockUser, async_test +) + + +class SnekboxTests(unittest.TestCase): + def setUp(self): + """Add mocked bot and cog to the instance.""" + self.bot = MockBot() + + self.mocked_post = MagicMock() + self.mocked_post.json = AsyncMock() + self.bot.http_session.post = MagicMock(return_value=AsyncContextManagerMock(self.mocked_post)) + + self.cog = Snekbox(bot=self.bot) + + @async_test + async def test_post_eval(self): + """Post the eval code to the URLs.snekbox_eval_api endpoint.""" + await self.cog.post_eval("import random") + self.bot.http_session.post.assert_called_once_with( + URLs.snekbox_eval_api, + json={"input": "import random"}, + raise_for_status=True + ) + + @async_test + async def test_upload_output_reject_too_long(self): + """Reject output longer than MAX_PASTE_LEN.""" + self.assertEqual(await self.cog.upload_output("-" * (snekbox.MAX_PASTE_LEN + 1)), "too long to upload") + + @async_test + async def test_upload_output(self): + """Upload the eval output to the URLs.paste_service.format(key="documents") endpoint.""" + key = "RainbowDash" + self.mocked_post.json.return_value = {"key": key} + + self.assertEqual( + await self.cog.upload_output("My awesome output"), + URLs.paste_service.format(key=key) + ) + self.bot.http_session.post.assert_called_once_with( + URLs.paste_service.format(key="documents"), + data="My awesome output", + raise_for_status=True + ) + + @async_test + async def test_upload_output_gracefully_fallback_if_exception_during_request(self): + """Output upload gracefully fallback if the upload fail.""" + self.mocked_post.json.side_effect = Exception + log = logging.getLogger("bot.cogs.snekbox") + with self.assertLogs(logger=log, level='ERROR'): + await self.cog.upload_output('My awesome output!') + + @async_test + 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.mocked_post.json.return_value = {} + 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}.', case=case, expected=expected): + 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): + self.assertEqual(self.cog.get_results_message({'stdout': stdout, 'returncode': returncode}), 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): + self.assertEqual(self.cog.get_status_emoji({'stdout': stdout, 'returncode': returncode}), expected) + + @async_test + 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, 20 Dec 2019 20:55:03 -0800 Subject: Bot: add wait_until_guild_available This coroutine waits until the configured guild is available and ensures the cache is present. The on_ready event is inadequate because it only waits 2 seconds for a GUILD_CREATE gateway event before giving up and thus not populating the cache for unavailable guilds. --- bot/bot.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/bot/bot.py b/bot/bot.py index 8f808272f..c0f31911c 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -1,11 +1,14 @@ +import asyncio import logging import socket from typing import Optional import aiohttp +import discord from discord.ext import commands from bot import api +from bot import constants log = logging.getLogger('bot') @@ -24,6 +27,8 @@ class Bot(commands.Bot): super().__init__(*args, connector=self.connector, **kwargs) + self._guild_available = asyncio.Event() + self.http_session: Optional[aiohttp.ClientSession] = None self.api_client = api.APIClient(loop=self.loop, connector=self.connector) @@ -51,3 +56,37 @@ class Bot(commands.Bot): self.http_session = aiohttp.ClientSession(connector=self.connector) await super().start(*args, **kwargs) + + async def on_guild_available(self, guild: discord.Guild) -> None: + """ + Set the internal guild available event when constants.Guild.id becomes available. + + If the cache appears to still be empty (no members, no channels, or no roles), the event + will not be set. + """ + if guild.id != constants.Guild.id: + return + + if not guild.roles or not guild.members or not guild.channels: + log.warning( + "Guild available event was dispatched but the cache appears to still be empty!" + ) + return + + self._guild_available.set() + + async def on_guild_unavailable(self, guild: discord.Guild) -> None: + """Clear the internal guild available event when constants.Guild.id becomes unavailable.""" + if guild.id != constants.Guild.id: + return + + self._guild_available.clear() + + async def wait_until_guild_available(self) -> None: + """ + Wait until the constants.Guild.id guild is available (and the cache is ready). + + The on_ready event is inadequate because it only waits 2 seconds for a GUILD_CREATE + gateway event before giving up and thus not populating the cache for unavailable guilds. + """ + await self._guild_available.wait() -- cgit v1.2.3 From c1a86468df6c157343a9a9f0ac69a22c412c6cdf Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 20 Dec 2019 20:55:42 -0800 Subject: Bot: make the connector attribute private --- bot/bot.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index c0f31911c..e5b9717db 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -20,17 +20,17 @@ class Bot(commands.Bot): # Use asyncio for DNS resolution instead of threads so threads aren't spammed. # Use AF_INET as its socket family to prevent HTTPS related problems both locally # and in production. - self.connector = aiohttp.TCPConnector( + self._connector = aiohttp.TCPConnector( resolver=aiohttp.AsyncResolver(), family=socket.AF_INET, ) - super().__init__(*args, connector=self.connector, **kwargs) + super().__init__(*args, connector=self._connector, **kwargs) self._guild_available = asyncio.Event() self.http_session: Optional[aiohttp.ClientSession] = None - self.api_client = api.APIClient(loop=self.loop, connector=self.connector) + self.api_client = api.APIClient(loop=self.loop, connector=self._connector) log.addHandler(api.APILoggingHandler(self.api_client)) @@ -53,7 +53,7 @@ class Bot(commands.Bot): async def start(self, *args, **kwargs) -> None: """Open an aiohttp session before logging in and connecting to Discord.""" - self.http_session = aiohttp.ClientSession(connector=self.connector) + self.http_session = aiohttp.ClientSession(connector=self._connector) await super().start(*args, **kwargs) -- cgit v1.2.3 From 04d8e1410d8839e4147522094ca40e41fe6e48e7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 20 Dec 2019 21:05:03 -0800 Subject: Use wait_until_guild_available instead of wait_until_ready It has a much better guarantee that the cache will be available. --- bot/cogs/antispam.py | 2 +- bot/cogs/defcon.py | 2 +- bot/cogs/doc.py | 2 +- bot/cogs/duck_pond.py | 2 +- bot/cogs/logging.py | 2 +- bot/cogs/moderation/scheduler.py | 2 +- bot/cogs/off_topic_names.py | 2 +- bot/cogs/reddit.py | 4 ++-- bot/cogs/reminders.py | 2 +- bot/cogs/sync/cog.py | 2 +- bot/cogs/verification.py | 2 +- bot/cogs/watchchannels/watchchannel.py | 2 +- 12 files changed, 13 insertions(+), 13 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index f67ef6f05..baa6b9459 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -123,7 +123,7 @@ class AntiSpam(Cog): async def alert_on_validation_error(self) -> None: """Unloads the cog and alerts admins if configuration validation failed.""" - await self.bot.wait_until_ready() + 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()) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 3e7350fcc..b97e2356f 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -59,7 +59,7 @@ class Defcon(Cog): async def sync_settings(self) -> None: """On cog load, try to synchronize DEFCON settings to the API.""" - await self.bot.wait_until_ready() + await self.bot.wait_until_guild_available() self.channel = await self.bot.fetch_channel(Channels.defcon) try: diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 6e7c00b6a..204cffb37 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -157,7 +157,7 @@ class Doc(commands.Cog): async def init_refresh_inventory(self) -> None: """Refresh documentation inventory on cog initialization.""" - await self.bot.wait_until_ready() + await self.bot.wait_until_guild_available() await self.refresh_inventory() async def update_single( diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index 345d2856c..1f84a0609 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -22,7 +22,7 @@ class DuckPond(Cog): async def fetch_webhook(self) -> None: """Fetches the webhook object, so we can post to it.""" - await self.bot.wait_until_ready() + await self.bot.wait_until_guild_available() try: self.webhook = await self.bot.fetch_webhook(self.webhook_id) diff --git a/bot/cogs/logging.py b/bot/cogs/logging.py index d1b7dcab3..dbd76672f 100644 --- a/bot/cogs/logging.py +++ b/bot/cogs/logging.py @@ -20,7 +20,7 @@ class Logging(Cog): async def startup_greeting(self) -> None: """Announce our presence to the configured devlog channel.""" - await self.bot.wait_until_ready() + await self.bot.wait_until_guild_available() log.info("Bot connected!") embed = Embed(description="Connected!") diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index e14c302cb..a332fefa5 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -38,7 +38,7 @@ class InfractionScheduler(Scheduler): async def reschedule_infractions(self, supported_infractions: t.Container[str]) -> None: """Schedule expiration for previous infractions.""" - await self.bot.wait_until_ready() + await self.bot.wait_until_guild_available() log.trace(f"Rescheduling infractions for {self.__class__.__name__}.") diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index bf777ea5a..81511f99d 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -88,7 +88,7 @@ class OffTopicNames(Cog): 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_ready() + 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) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index aa487f18e..4f6584aba 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -48,7 +48,7 @@ class Reddit(Cog): async def init_reddit_ready(self) -> None: """Sets the reddit webhook when the cog is loaded.""" - await self.bot.wait_until_ready() + await self.bot.wait_until_guild_available() if not self.webhook: self.webhook = await self.bot.fetch_webhook(Webhooks.reddit) @@ -208,7 +208,7 @@ class Reddit(Cog): await asyncio.sleep(seconds_until) - await self.bot.wait_until_ready() + await self.bot.wait_until_guild_available() if not self.webhook: await self.bot.fetch_webhook(Webhooks.reddit) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 45bf9a8f4..89066e5d4 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -35,7 +35,7 @@ class Reminders(Scheduler, Cog): async def reschedule_reminders(self) -> None: """Get all current reminders from the API and reschedule them.""" - await self.bot.wait_until_ready() + await self.bot.wait_until_guild_available() response = await self.bot.api_client.get( 'bot/reminders', params={'active': 'true'} diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 4e6ed156b..9ef3b0c54 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -34,7 +34,7 @@ class Sync(Cog): async def sync_guild(self) -> None: """Syncs the roles/users of the guild with the database.""" - await self.bot.wait_until_ready() + await self.bot.wait_until_guild_available() guild = self.bot.get_guild(self.SYNC_SERVER_ID) if guild is not None: for syncer in self.ON_READY_SYNCERS: diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 988e0d49a..07838c7bd 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -223,7 +223,7 @@ class Verification(Cog): @periodic_ping.before_loop async def before_ping(self) -> None: """Only start the loop when the bot is ready.""" - await self.bot.wait_until_ready() + await self.bot.wait_until_guild_available() def cog_unload(self) -> None: """Cancel the periodic ping task when the cog is unloaded.""" diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index eb787b083..3667a80e8 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -91,7 +91,7 @@ class WatchChannel(metaclass=CogABCMeta): async def start_watchchannel(self) -> None: """Starts the watch channel by getting the channel, webhook, and user cache ready.""" - await self.bot.wait_until_ready() + await self.bot.wait_until_guild_available() try: self.channel = await self.bot.fetch_channel(self.destination) -- cgit v1.2.3 From e980dab7aa7e2fb6a402b452a376bf94f899989d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 20 Dec 2019 21:26:46 -0800 Subject: Constants: add dev-core channel and check mark emoji --- bot/constants.py | 2 ++ config-default.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index fe8e57322..6279388de 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -263,6 +263,7 @@ class Emojis(metaclass=YAMLGetter): new: str pencil: str cross_mark: str + check_mark: str ducky_yellow: int ducky_blurple: int @@ -365,6 +366,7 @@ class Channels(metaclass=YAMLGetter): bot: int checkpoint_test: int defcon: int + devcore: int devlog: int devtest: int esoteric: int diff --git a/config-default.yml b/config-default.yml index fda14b511..74dcc1862 100644 --- a/config-default.yml +++ b/config-default.yml @@ -34,6 +34,7 @@ style: pencil: "\u270F" new: "\U0001F195" cross_mark: "\u274C" + check_mark: "\u2705" ducky_yellow: &DUCKY_YELLOW 574951975574175744 ducky_blurple: &DUCKY_BLURPLE 574951975310065675 @@ -121,6 +122,7 @@ guild: bot: 267659945086812160 checkpoint_test: 422077681434099723 defcon: &DEFCON 464469101889454091 + devcore: 411200599653351425 devlog: &DEVLOG 622895325144940554 devtest: &DEVTEST 414574275865870337 esoteric: 470884583684964352 -- cgit v1.2.3 From 705963a2bf477b8536846683f9f2598ee788d3dc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 21 Dec 2019 11:16:07 -0800 Subject: API: define functions with keyword-only arguments This seems to have been the intent of the original implementation. --- bot/api.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/bot/api.py b/bot/api.py index 56db99828..992499809 100644 --- a/bot/api.py +++ b/bot/api.py @@ -85,43 +85,43 @@ class APIClient: response_text = await response.text() raise ResponseCodeError(response=response, response_text=response_text) - async def get(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict: + async def get(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: """Site API GET.""" await self._ready.wait() - async with self.session.get(self._url_for(endpoint), *args, **kwargs) as resp: + async with self.session.get(self._url_for(endpoint), **kwargs) as resp: await self.maybe_raise_for_status(resp, raise_for_status) return await resp.json() - async def patch(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict: + async def patch(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: """Site API PATCH.""" await self._ready.wait() - async with self.session.patch(self._url_for(endpoint), *args, **kwargs) as resp: + async with self.session.patch(self._url_for(endpoint), **kwargs) as resp: await self.maybe_raise_for_status(resp, raise_for_status) return await resp.json() - async def post(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict: + async def post(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: """Site API POST.""" await self._ready.wait() - async with self.session.post(self._url_for(endpoint), *args, **kwargs) as resp: + async with self.session.post(self._url_for(endpoint), **kwargs) as resp: await self.maybe_raise_for_status(resp, raise_for_status) return await resp.json() - async def put(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict: + async def put(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: """Site API PUT.""" await self._ready.wait() - async with self.session.put(self._url_for(endpoint), *args, **kwargs) as resp: + async with self.session.put(self._url_for(endpoint), **kwargs) as resp: await self.maybe_raise_for_status(resp, raise_for_status) return await resp.json() - async def delete(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> Optional[dict]: + async def delete(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> Optional[dict]: """Site API DELETE.""" await self._ready.wait() - async with self.session.delete(self._url_for(endpoint), *args, **kwargs) as resp: + async with self.session.delete(self._url_for(endpoint), **kwargs) as resp: if resp.status == 204: return None -- cgit v1.2.3 From d3bc9a978e2ff348ff33dfef26f430d59b89695f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 21 Dec 2019 11:27:00 -0800 Subject: API: create request function which has a param for the HTTP method Reduces code redundancy. --- bot/api.py | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/bot/api.py b/bot/api.py index 992499809..a9d2baa4d 100644 --- a/bot/api.py +++ b/bot/api.py @@ -85,37 +85,29 @@ class APIClient: response_text = await response.text() raise ResponseCodeError(response=response, response_text=response_text) - async def get(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: - """Site API GET.""" + async def request(self, method: str, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: + """Send an HTTP request to the site API and return the JSON response.""" await self._ready.wait() - async with self.session.get(self._url_for(endpoint), **kwargs) as resp: + async with self.session.request(method.upper(), self._url_for(endpoint), **kwargs) as resp: await self.maybe_raise_for_status(resp, raise_for_status) return await resp.json() + async def get(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: + """Site API GET.""" + return await self.request("GET", endpoint, raise_for_status=raise_for_status, **kwargs) + async def patch(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: """Site API PATCH.""" - await self._ready.wait() - - async with self.session.patch(self._url_for(endpoint), **kwargs) as resp: - await self.maybe_raise_for_status(resp, raise_for_status) - return await resp.json() + return await self.request("PATCH", endpoint, raise_for_status=raise_for_status, **kwargs) async def post(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: """Site API POST.""" - await self._ready.wait() - - async with self.session.post(self._url_for(endpoint), **kwargs) as resp: - await self.maybe_raise_for_status(resp, raise_for_status) - return await resp.json() + return await self.request("POST", endpoint, raise_for_status=raise_for_status, **kwargs) async def put(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: """Site API PUT.""" - await self._ready.wait() - - async with self.session.put(self._url_for(endpoint), **kwargs) as resp: - await self.maybe_raise_for_status(resp, raise_for_status) - return await resp.json() + return await self.request("PUT", endpoint, raise_for_status=raise_for_status, **kwargs) async def delete(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> Optional[dict]: """Site API DELETE.""" -- cgit v1.2.3 From cc8b58bf7a1937a78e2f4edf9a67ad4460bb84dd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 21 Dec 2019 09:55:47 -0800 Subject: Sync: refactor cog * Use ID from constants directly instead of SYNC_SERVER_ID * Use f-strings instead of %s for logging * Fit into margin of 100 * Invert condition to reduce nesting * Use Any instead of incorrect function annotation for JSON values --- bot/cogs/sync/cog.py | 51 ++++++++++++++++++++++++++------------------------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 9ef3b0c54..eff942cdb 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -1,5 +1,5 @@ import logging -from typing import Callable, Dict, Iterable, Union +from typing import Any, Callable, Dict, Iterable from discord import Guild, Member, Role, User from discord.ext import commands @@ -16,11 +16,6 @@ log = logging.getLogger(__name__) class Sync(Cog): """Captures relevant events and sends them to the site.""" - # The server to synchronize events on. - # Note that setting this wrongly will result in things getting deleted - # that possibly shouldn't be. - SYNC_SERVER_ID = constants.Guild.id - # An iterable of callables that are called when the bot is ready. ON_READY_SYNCERS: Iterable[Callable[[Bot, Guild], None]] = ( syncers.sync_roles, @@ -35,26 +30,31 @@ class Sync(Cog): 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(self.SYNC_SERVER_ID) - if guild is not None: - for syncer in self.ON_READY_SYNCERS: - syncer_name = syncer.__name__[5:] # drop off `sync_` - log.info("Starting `%s` syncer.", syncer_name) - total_created, total_updated, total_deleted = await syncer(self.bot, guild) - if total_deleted is None: - log.info( - f"`{syncer_name}` syncer finished, created `{total_created}`, updated `{total_updated}`." - ) - else: - log.info( - f"`{syncer_name}` syncer finished, created `{total_created}`, updated `{total_updated}`, " - f"deleted `{total_deleted}`." - ) - - async def patch_user(self, user_id: int, updated_information: Dict[str, Union[str, int]]) -> None: + + guild = self.bot.get_guild(constants.Guild.id) + if guild is None: + return + + for syncer in self.ON_READY_SYNCERS: + syncer_name = syncer.__name__[5:] # drop off `sync_` + log.info(f"Starting {syncer_name} syncer.") + total_created, total_updated, total_deleted = await syncer(self.bot, guild) + + if total_deleted is None: + log.info( + f"`{syncer_name}` syncer finished: created `{total_created}`, " + f"updated `{total_updated}`." + ) + else: + log.info( + f"`{syncer_name}` syncer finished: created `{total_created}`, " + f"updated `{total_updated}`, deleted `{total_deleted}`." + ) + + async def patch_user(self, user_id: int, updated_information: Dict[str, Any]) -> None: """Send a PATCH request to partially update a user in the database.""" try: - await self.bot.api_client.patch("bot/users/" + str(user_id), json=updated_information) + await self.bot.api_client.patch(f"bot/users/{user_id}", json=updated_information) except ResponseCodeError as e: if e.response.status != 404: raise @@ -160,7 +160,8 @@ class Sync(Cog): @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.""" - if any(getattr(before, attr) != getattr(after, attr) for attr in ("name", "discriminator", "avatar")): + attrs = ("name", "discriminator", "avatar") + if any(getattr(before, attr) != getattr(after, attr) for attr in attrs): updated_information = { "name": after.name, "discriminator": int(after.discriminator), -- cgit v1.2.3 From cad6882b2a777041ba0eef3ac4a19b51ac092b60 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 21 Dec 2019 14:05:36 -0800 Subject: Sync: create function for running a single syncer --- bot/cogs/sync/cog.py | 46 ++++++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index eff942cdb..cefecd163 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -1,5 +1,5 @@ import logging -from typing import Any, Callable, Dict, Iterable +from typing import Any, Callable, Coroutine, Dict, Optional, Tuple from discord import Guild, Member, Role, User from discord.ext import commands @@ -12,16 +12,13 @@ from bot.cogs.sync import syncers log = logging.getLogger(__name__) +SyncerResult = Tuple[Optional[int], Optional[int], Optional[int]] +Syncer = Callable[[Bot, Guild], Coroutine[Any, Any, SyncerResult]] + class Sync(Cog): """Captures relevant events and sends them to the site.""" - # An iterable of callables that are called when the bot is ready. - ON_READY_SYNCERS: Iterable[Callable[[Bot, Guild], None]] = ( - syncers.sync_roles, - syncers.sync_users - ) - def __init__(self, bot: Bot) -> None: self.bot = bot @@ -35,21 +32,26 @@ class Sync(Cog): if guild is None: return - for syncer in self.ON_READY_SYNCERS: - syncer_name = syncer.__name__[5:] # drop off `sync_` - log.info(f"Starting {syncer_name} syncer.") - total_created, total_updated, total_deleted = await syncer(self.bot, guild) - - if total_deleted is None: - log.info( - f"`{syncer_name}` syncer finished: created `{total_created}`, " - f"updated `{total_updated}`." - ) - else: - log.info( - f"`{syncer_name}` syncer finished: created `{total_created}`, " - f"updated `{total_updated}`, deleted `{total_deleted}`." - ) + for syncer_name in (syncers.sync_roles, syncers.sync_users): + await self.sync(syncer_name, guild) + + async def sync(self, syncer: Syncer, guild: Guild) -> None: + """Run the named syncer for the given guild.""" + syncer_name = syncer.__name__[5:] # drop off `sync_` + + log.info(f"Starting {syncer_name} syncer.") + total_created, total_updated, total_deleted = await syncer(self.bot, guild) + + if total_deleted is None: + log.info( + f"`{syncer_name}` syncer finished: created `{total_created}`, " + f"updated `{total_updated}`." + ) + else: + log.info( + f"`{syncer_name}` syncer finished: created `{total_created}`, " + f"updated `{total_updated}`, deleted `{total_deleted}`." + ) async def patch_user(self, user_id: int, updated_information: Dict[str, Any]) -> None: """Send a PATCH request to partially update a user in the database.""" -- cgit v1.2.3 From 0d8890b6762e0066fccf4e8a0b8f9759f5b1d4a8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 21 Dec 2019 14:15:12 -0800 Subject: Sync: support multiple None totals returns from a syncer --- bot/cogs/sync/cog.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index cefecd163..ccfbd201d 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -40,18 +40,15 @@ class Sync(Cog): syncer_name = syncer.__name__[5:] # drop off `sync_` log.info(f"Starting {syncer_name} syncer.") - total_created, total_updated, total_deleted = await syncer(self.bot, guild) - if total_deleted is None: - log.info( - f"`{syncer_name}` syncer finished: created `{total_created}`, " - f"updated `{total_updated}`." - ) + totals = await syncer(self.bot, guild) + totals = zip(("created", "updated", "deleted"), totals) + results = ", ".join(f"{name} `{total}`" for name, total in totals if total is not None) + + if results: + log.info(f"`{syncer_name}` syncer finished: {results}.") else: - log.info( - f"`{syncer_name}` syncer finished: created `{total_created}`, " - f"updated `{total_updated}`, deleted `{total_deleted}`." - ) + log.warning(f"`{syncer_name}` syncer aborted!") async def patch_user(self, user_id: int, updated_information: Dict[str, Any]) -> None: """Send a PATCH request to partially update a user in the database.""" -- cgit v1.2.3 From 471efe41fa226e5890d715c24549c808603274e9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 21 Dec 2019 14:21:35 -0800 Subject: Sync: support sending messages to a context in sync() --- bot/cogs/sync/cog.py | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index ccfbd201d..a80906cae 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -35,11 +35,13 @@ class Sync(Cog): for syncer_name in (syncers.sync_roles, syncers.sync_users): await self.sync(syncer_name, guild) - async def sync(self, syncer: Syncer, guild: Guild) -> None: + async def sync(self, syncer: Syncer, guild: Guild, ctx: Optional[Context] = None) -> None: """Run the named syncer for the given guild.""" syncer_name = syncer.__name__[5:] # drop off `sync_` log.info(f"Starting {syncer_name} syncer.") + if ctx: + message = await ctx.send(f"📊 Synchronizing {syncer_name}.") totals = await syncer(self.bot, guild) totals = zip(("created", "updated", "deleted"), totals) @@ -47,8 +49,14 @@ class Sync(Cog): if results: log.info(f"`{syncer_name}` syncer finished: {results}.") + if ctx: + await message.edit( + content=f":ok_hand: Synchronization of {syncer_name} complete: {results}" + ) else: log.warning(f"`{syncer_name}` syncer aborted!") + if ctx: + await message.edit(content=f":x: Synchronization of {syncer_name} aborted!") async def patch_user(self, user_id: int, updated_information: Dict[str, Any]) -> None: """Send a PATCH request to partially update a user in the database.""" @@ -177,24 +185,10 @@ class Sync(Cog): @commands.has_permissions(administrator=True) async def sync_roles_command(self, ctx: Context) -> None: """Manually synchronize the guild's roles with the roles on the site.""" - initial_response = await ctx.send("📊 Synchronizing roles.") - total_created, total_updated, total_deleted = await syncers.sync_roles(self.bot, ctx.guild) - await initial_response.edit( - content=( - f"👌 Role synchronization complete, created **{total_created}** " - f", updated **{total_created}** roles, and deleted **{total_deleted}** roles." - ) - ) + await self.sync(syncers.sync_roles, ctx.guild, ctx) @sync_group.command(name='users') @commands.has_permissions(administrator=True) async def sync_users_command(self, ctx: Context) -> None: """Manually synchronize the guild's users with the users on the site.""" - initial_response = await ctx.send("📊 Synchronizing users.") - total_created, total_updated, total_deleted = await syncers.sync_users(self.bot, ctx.guild) - await initial_response.edit( - content=( - f"👌 User synchronization complete, created **{total_created}** " - f"and updated **{total_created}** users." - ) - ) + await self.sync(syncers.sync_users, ctx.guild, ctx) -- cgit v1.2.3 From 9120159ce61e9a0d50f077627701404daa6c416e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 24 Dec 2019 21:08:22 -0800 Subject: Sync: create classes for syncers Replaces the functions with a class for each syncer. The classes inherit from a Syncer base class. A NamedTuple was also created to replace the tuple of the object differences that was previously being returned. * Use namedtuple._asdict to simplify converting namedtuples to JSON --- bot/cogs/sync/cog.py | 38 ++--- bot/cogs/sync/syncers.py | 362 ++++++++++++++++++----------------------------- 2 files changed, 158 insertions(+), 242 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index a80906cae..1670278e0 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -1,5 +1,5 @@ import logging -from typing import Any, Callable, Coroutine, Dict, Optional, Tuple +from typing import Any, Dict, Optional from discord import Guild, Member, Role, User from discord.ext import commands @@ -12,15 +12,14 @@ from bot.cogs.sync import syncers log = logging.getLogger(__name__) -SyncerResult = Tuple[Optional[int], Optional[int], Optional[int]] -Syncer = Callable[[Bot, Guild], Coroutine[Any, Any, SyncerResult]] - 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.api_client) + self.user_syncer = syncers.UserSyncer(self.bot.api_client) self.bot.loop.create_task(self.sync_guild()) @@ -32,31 +31,34 @@ class Sync(Cog): if guild is None: return - for syncer_name in (syncers.sync_roles, syncers.sync_users): - await self.sync(syncer_name, guild) + for syncer in (self.role_syncer, self.user_syncer): + await self.sync(syncer, guild) - async def sync(self, syncer: Syncer, guild: Guild, ctx: Optional[Context] = None) -> None: + @staticmethod + async def sync(syncer: syncers.Syncer, guild: Guild, ctx: Optional[Context] = None) -> None: """Run the named syncer for the given guild.""" - syncer_name = syncer.__name__[5:] # drop off `sync_` + syncer_name = syncer.__class__.__name__[-6:].lower() # Drop off "Syncer" suffix log.info(f"Starting {syncer_name} syncer.") if ctx: - message = await ctx.send(f"📊 Synchronizing {syncer_name}.") + message = await ctx.send(f"📊 Synchronizing {syncer_name}s.") + + diff = await syncer.get_diff(guild) + await syncer.sync(diff) - totals = await syncer(self.bot, guild) - totals = zip(("created", "updated", "deleted"), totals) - results = ", ".join(f"{name} `{total}`" for name, total in totals if total is not None) + totals = zip(("created", "updated", "deleted"), diff) + results = ", ".join(f"{name} `{len(total)}`" for name, total in totals if total is not None) if results: - log.info(f"`{syncer_name}` syncer finished: {results}.") + log.info(f"{syncer_name} syncer finished: {results}.") if ctx: await message.edit( - content=f":ok_hand: Synchronization of {syncer_name} complete: {results}" + content=f":ok_hand: Synchronization of {syncer_name}s complete: {results}" ) else: - log.warning(f"`{syncer_name}` syncer aborted!") + log.warning(f"{syncer_name} syncer aborted!") if ctx: - await message.edit(content=f":x: Synchronization of {syncer_name} aborted!") + await message.edit(content=f":x: Synchronization of {syncer_name}s aborted!") async def patch_user(self, user_id: int, updated_information: Dict[str, Any]) -> None: """Send a PATCH request to partially update a user in the database.""" @@ -185,10 +187,10 @@ class Sync(Cog): @commands.has_permissions(administrator=True) async def sync_roles_command(self, ctx: Context) -> None: """Manually synchronize the guild's roles with the roles on the site.""" - await self.sync(syncers.sync_roles, ctx.guild, ctx) + await self.sync(self.role_syncer, ctx.guild, ctx) @sync_group.command(name='users') @commands.has_permissions(administrator=True) async def sync_users_command(self, ctx: Context) -> None: """Manually synchronize the guild's users with the users on the site.""" - await self.sync(syncers.sync_users, ctx.guild, ctx) + await self.sync(self.user_syncer, ctx.guild, ctx) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 14cf51383..356831922 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -1,9 +1,12 @@ +import abc +import typing as t from collections import namedtuple -from typing import Dict, Set, Tuple from discord import Guild -from bot.bot import Bot +from bot.api import APIClient + +_T = t.TypeVar("_T") # These objects are declared as namedtuples because tuples are hashable, # something that we make use of when diffing site roles against guild roles. @@ -11,225 +14,136 @@ Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions', 'position')) User = namedtuple('User', ('id', 'name', 'discriminator', 'avatar_hash', 'roles', 'in_guild')) -def get_roles_for_sync( - guild_roles: Set[Role], api_roles: Set[Role] -) -> Tuple[Set[Role], Set[Role], Set[Role]]: - """ - Determine which roles should be created or updated on the site. - - Arguments: - guild_roles (Set[Role]): - Roles that were found on the guild at startup. - - api_roles (Set[Role]): - Roles that were retrieved from the API at startup. - - Returns: - Tuple[Set[Role], Set[Role]. Set[Role]]: - A tuple with three elements. The first element represents - roles to be created on the site, meaning that they were - present on the cached guild but not on the API. The second - element represents roles to be updated, meaning they were - present on both the cached guild and the API but non-ID - fields have changed inbetween. The third represents roles - to be deleted on the site, meaning the roles are present on - the API but not in the cached guild. - """ - guild_role_ids = {role.id for role in guild_roles} - api_role_ids = {role.id for role in api_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 - # API 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 - api_roles - roles_to_create - roles_to_delete = {role for role in api_roles if role.id in deleted_role_ids} - return roles_to_create, roles_to_update, roles_to_delete - - -async def sync_roles(bot: Bot, guild: Guild) -> Tuple[int, int, int]: - """ - Synchronize roles found on the given `guild` with the ones on the API. - - Arguments: - bot (bot.bot.Bot): - The bot instance that we're running with. - - guild (discord.Guild): - The guild instance from the bot's cache - to synchronize roles with. - - Returns: - Tuple[int, int, int]: - A tuple with three integers representing how many roles were created - (element `0`) , how many roles were updated (element `1`), and how many - roles were deleted (element `2`) on the API. - """ - roles = await bot.api_client.get('bot/roles') - - # Pack API roles and guild roles into one common format, - # which is also hashable. We need hashability to be able - # to compare these easily later using sets. - api_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 - } - roles_to_create, roles_to_update, roles_to_delete = get_roles_for_sync(guild_roles, api_roles) - - for role in roles_to_create: - await bot.api_client.post( - 'bot/roles', - json={ - 'id': role.id, - 'name': role.name, - 'colour': role.colour, - 'permissions': role.permissions, - 'position': role.position, - } - ) - - for role in roles_to_update: - await bot.api_client.put( - f'bot/roles/{role.id}', - json={ - 'id': role.id, - 'name': role.name, - 'colour': role.colour, - 'permissions': role.permissions, - 'position': role.position, - } - ) - - for role in roles_to_delete: - await bot.api_client.delete(f'bot/roles/{role.id}') - - return len(roles_to_create), len(roles_to_update), len(roles_to_delete) - - -def get_users_for_sync( - guild_users: Dict[int, User], api_users: Dict[int, User] -) -> Tuple[Set[User], Set[User]]: - """ - Determine which users should be created or updated on the website. - - Arguments: - guild_users (Dict[int, User]): - A mapping of user IDs to user data, populated from the - guild cached on the running bot instance. - - api_users (Dict[int, User]): - A mapping of user IDs to user data, populated from the API's - current inventory of all users. - - Returns: - Tuple[Set[User], Set[User]]: - Two user sets as a tuple. The first element represents users - to be created on the website, these are users that are present - in the cached guild data but not in the API at all, going by - their ID. The second element represents users to update. It is - populated by users which are present on both the API and the - guild, but where the attribute of a user on the API is not - equal to the attribute of the user on the guild. - """ - users_to_create = set() - users_to_update = set() - - for api_user in api_users.values(): - guild_user = guild_users.get(api_user.id) - if guild_user is not None: - if api_user != guild_user: - users_to_update.add(guild_user) - - elif api_user.in_guild: - # The user is known on the API but not the guild, and the - # API 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 = api_user._replace(in_guild=False) - users_to_update.add(new_api_user) - - new_user_ids = set(guild_users.keys()) - set(api_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 users_to_create, users_to_update - - -async def sync_users(bot: Bot, guild: Guild) -> Tuple[int, int, None]: - """ - Synchronize users found in the given `guild` with the ones in the API. - - Arguments: - bot (bot.bot.Bot): - The bot instance that we're running with. - - guild (discord.Guild): - The guild instance from the bot's cache - to synchronize roles with. - - Returns: - Tuple[int, int, None]: - A tuple with two integers, representing how many users were created - (element `0`) and how many users were updated (element `1`), and `None` - to indicate that a user sync never deletes entries from the API. - """ - current_users = await bot.api_client.get('bot/users') - - # Pack API users and guild users into one common format, - # which is also hashable. We need hashability to be able - # to compare these easily later using sets. - api_users = { - user_dict['id']: User( - roles=tuple(sorted(user_dict.pop('roles'))), - **user_dict - ) - for user_dict in current_users - } - guild_users = { - member.id: User( - id=member.id, name=member.name, - discriminator=int(member.discriminator), avatar_hash=member.avatar, - roles=tuple(sorted(role.id for role in member.roles)), in_guild=True - ) - for member in guild.members - } - - users_to_create, users_to_update = get_users_for_sync(guild_users, api_users) - - for user in users_to_create: - await bot.api_client.post( - 'bot/users', - json={ - 'avatar_hash': user.avatar_hash, - 'discriminator': user.discriminator, - 'id': user.id, - 'in_guild': user.in_guild, - 'name': user.name, - 'roles': list(user.roles) - } - ) - - for user in users_to_update: - await bot.api_client.put( - f'bot/users/{user.id}', - json={ - 'avatar_hash': user.avatar_hash, - 'discriminator': user.discriminator, - 'id': user.id, - 'in_guild': user.in_guild, - 'name': user.name, - 'roles': list(user.roles) - } - ) - - return len(users_to_create), len(users_to_update), None +class Diff(t.NamedTuple, t.Generic[_T]): + """The differences between the Discord cache and the contents of the database.""" + + created: t.Optional[t.Set[_T]] = None + updated: t.Optional[t.Set[_T]] = None + deleted: t.Optional[t.Set[_T]] = None + + +class Syncer(abc.ABC, t.Generic[_T]): + """Base class for synchronising the database with objects in the Discord cache.""" + + def __init__(self, api_client: APIClient) -> None: + self.api_client = api_client + + @abc.abstractmethod + async def get_diff(self, guild: Guild) -> Diff[_T]: + """Return objects of `guild` with which to synchronise the database.""" + raise NotImplementedError + + @abc.abstractmethod + async def sync(self, diff: Diff[_T]) -> None: + """Synchronise the database with the given `diff`.""" + raise NotImplementedError + + +class RoleSyncer(Syncer[Role]): + """Synchronise the database with roles in the cache.""" + + async def get_diff(self, guild: Guild) -> Diff[Role]: + """Return the roles of `guild` with which to synchronise the database.""" + roles = await self.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[Role]) -> None: + """Synchronise roles in the database with the given `diff`.""" + for role in diff.created: + await self.api_client.post('bot/roles', json={**role._asdict()}) + + for role in diff.updated: + await self.api_client.put(f'bot/roles/{role.id}', json={**role._asdict()}) + + for role in diff.deleted: + await self.api_client.delete(f'bot/roles/{role.id}') + + +class UserSyncer(Syncer[User]): + """Synchronise the database with users in the cache.""" + + async def get_diff(self, guild: Guild) -> Diff[User]: + """Return the users of `guild` with which to synchronise the database.""" + users = await self.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), + avatar_hash=member.avatar, + 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) + + async def sync(self, diff: Diff[User]) -> None: + """Synchronise users in the database with the given `diff`.""" + for user in diff.created: + await self.api_client.post('bot/users', json={**user._asdict()}) + + for user in diff.updated: + await self.api_client.put(f'bot/users/{user.id}', json={**user._asdict()}) -- cgit v1.2.3 From 9e8fe747c155226756e01ab2961a7ae3cfdb6f19 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 24 Dec 2019 22:23:12 -0800 Subject: Sync: prompt to confirm when diff is greater than 10 The confirmation prompt will be sent to the dev-core channel or the specified context. Confirmation is done via reactions and waits 5 minutes before timing out. * Add name property to Syncers * Make _get_diff private; only sync() needs to be called now * Change spelling of synchronize to synchronise * Update docstrings --- bot/cogs/sync/cog.py | 25 ++++--- bot/cogs/sync/syncers.py | 170 ++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 158 insertions(+), 37 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 1670278e0..1fd39b544 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -36,29 +36,28 @@ class Sync(Cog): @staticmethod async def sync(syncer: syncers.Syncer, guild: Guild, ctx: Optional[Context] = None) -> None: - """Run the named syncer for the given guild.""" - syncer_name = syncer.__class__.__name__[-6:].lower() # Drop off "Syncer" suffix - - log.info(f"Starting {syncer_name} syncer.") + """Run `syncer` using the cache of the given `guild`.""" + log.info(f"Starting {syncer.name} syncer.") if ctx: - message = await ctx.send(f"📊 Synchronizing {syncer_name}s.") + message = await ctx.send(f"📊 Synchronising {syncer.name}s.") - diff = await syncer.get_diff(guild) - await syncer.sync(diff) + diff = await syncer.sync(guild, ctx) + if not diff: + return # Sync was aborted. totals = zip(("created", "updated", "deleted"), diff) results = ", ".join(f"{name} `{len(total)}`" for name, total in totals if total is not None) if results: - log.info(f"{syncer_name} syncer finished: {results}.") + log.info(f"{syncer.name} syncer finished: {results}.") if ctx: await message.edit( - content=f":ok_hand: Synchronization of {syncer_name}s complete: {results}" + content=f":ok_hand: Synchronisation of {syncer.name}s complete: {results}" ) else: - log.warning(f"{syncer_name} syncer aborted!") + log.warning(f"{syncer.name} syncer aborted!") if ctx: - await message.edit(content=f":x: Synchronization of {syncer_name}s aborted!") + await message.edit(content=f":x: Synchronisation of {syncer.name}s aborted!") async def patch_user(self, user_id: int, updated_information: Dict[str, Any]) -> None: """Send a PATCH request to partially update a user in the database.""" @@ -186,11 +185,11 @@ class Sync(Cog): @sync_group.command(name='roles') @commands.has_permissions(administrator=True) async def sync_roles_command(self, ctx: Context) -> None: - """Manually synchronize the guild's roles with the roles on the site.""" + """Manually synchronise the guild's roles with the roles on the site.""" await self.sync(self.role_syncer, ctx.guild, ctx) @sync_group.command(name='users') @commands.has_permissions(administrator=True) async def sync_users_command(self, ctx: Context) -> None: - """Manually synchronize the guild's users with the users on the site.""" + """Manually synchronise the guild's users with the users on the site.""" await self.sync(self.user_syncer, ctx.guild, ctx) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 356831922..7608c6870 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -1,18 +1,23 @@ import abc +import logging import typing as t from collections import namedtuple -from discord import Guild +from discord import Guild, HTTPException +from discord.ext.commands import Context -from bot.api import APIClient +from bot import constants +from bot.bot import Bot -_T = t.TypeVar("_T") +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', 'avatar_hash', 'roles', 'in_guild')) +_T = t.TypeVar("_T") + class Diff(t.NamedTuple, t.Generic[_T]): """The differences between the Discord cache and the contents of the database.""" @@ -25,26 +30,113 @@ class Diff(t.NamedTuple, t.Generic[_T]): class Syncer(abc.ABC, t.Generic[_T]): """Base class for synchronising the database with objects in the Discord cache.""" - def __init__(self, api_client: APIClient) -> None: - self.api_client = api_client + CONFIRM_TIMEOUT = 60 * 5 # 5 minutes + MAX_DIFF = 10 + def __init__(self, bot: Bot) -> None: + self.bot = bot + + @property @abc.abstractmethod - async def get_diff(self, guild: Guild) -> Diff[_T]: - """Return objects of `guild` with which to synchronise the database.""" + def name(self) -> str: + """The name of the syncer; used in output messages and logging.""" raise NotImplementedError + async def _confirm(self, ctx: t.Optional[Context] = None) -> bool: + """ + Send a prompt to confirm or abort a sync using reactions and return True if confirmed. + + If no context is given, the prompt is sent to the dev-core channel and mentions the core + developers role. + """ + allowed_emoji = (constants.Emojis.check_mark, constants.Emojis.cross_mark) + + # Send to core developers if it's an automatic sync. + if not ctx: + mention = f'<@&{constants.Roles.core_developer}>' + channel = self.bot.get_channel(constants.Channels.devcore) + + if not channel: + try: + channel = self.bot.fetch_channel(constants.Channels.devcore) + except HTTPException: + log.exception( + f"Failed to fetch channel for sending sync confirmation prompt; " + f"aborting {self.name} sync." + ) + return False + else: + mention = ctx.author.mention + channel = ctx.channel + + message = await channel.send( + f'{mention} Possible cache issue while syncing {self.name}s. ' + f'Found no {self.name}s or more than {self.MAX_DIFF} {self.name}s were changed. ' + f'React to confirm or abort the sync.' + ) + + # Add the initial reactions. + for emoji in allowed_emoji: + await message.add_reaction(emoji) + + def check(_reaction, user): # noqa: TYP + return ( + _reaction.message.id == message.id + and True if not ctx else user == ctx.author # Skip author check for auto syncs + and str(_reaction.emoji) in allowed_emoji + ) + + reaction = None + try: + reaction, _ = await self.bot.wait_for( + 'reaction_add', + check=check, + timeout=self.CONFIRM_TIMEOUT + ) + except TimeoutError: + # reaction will remain none thus sync will be aborted in the finally block below. + pass + finally: + if str(reaction) == constants.Emojis.check_mark: + await channel.send(f':ok_hand: {self.name} sync will proceed.') + return True + else: + await channel.send(f':x: {self.name} sync aborted!') + return False + @abc.abstractmethod - async def sync(self, diff: Diff[_T]) -> None: - """Synchronise the database with the given `diff`.""" + async def _get_diff(self, guild: Guild) -> Diff[_T]: + """Return the difference between the cache of `guild` and the database.""" raise NotImplementedError + @abc.abstractmethod + async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> t.Optional[Diff[_T]]: + """ + Synchronise the database with the cache of `guild` and return the synced difference. + + If the differences between the cache and the database are greater than `MAX_DIFF`, then + a confirmation prompt will be sent to the dev-core channel. The confirmation can be + optionally redirect to `ctx` instead. + + If the sync is not confirmed, None is returned. + """ + diff = await self._get_diff(guild) + confirmed = await self._confirm(ctx) + + if not confirmed: + return None + else: + return diff + class RoleSyncer(Syncer[Role]): """Synchronise the database with roles in the cache.""" - async def get_diff(self, guild: Guild) -> Diff[Role]: - """Return the roles of `guild` with which to synchronise the database.""" - roles = await self.api_client.get('bot/roles') + name = "role" + + async def _get_diff(self, guild: Guild) -> Diff[Role]: + """Return the difference of roles between the cache of `guild` and the database.""" + 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. @@ -73,24 +165,40 @@ class RoleSyncer(Syncer[Role]): return Diff(roles_to_create, roles_to_update, roles_to_delete) - async def sync(self, diff: Diff[Role]) -> None: - """Synchronise roles in the database with the given `diff`.""" + async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> t.Optional[Diff[Role]]: + """ + Synchronise the database with the role cache of `guild` and return the synced difference. + + If the differences between the cache and the database are greater than `MAX_DIFF`, then + a confirmation prompt will be sent to the dev-core channel. The confirmation can be + optionally redirect to `ctx` instead. + + If the sync is not confirmed, None is returned. + """ + diff = await super().sync(guild, ctx) + if diff is None: + return None + for role in diff.created: - await self.api_client.post('bot/roles', json={**role._asdict()}) + await self.bot.api_client.post('bot/roles', json={**role._asdict()}) for role in diff.updated: - await self.api_client.put(f'bot/roles/{role.id}', json={**role._asdict()}) + await self.bot.api_client.put(f'bot/roles/{role.id}', json={**role._asdict()}) for role in diff.deleted: - await self.api_client.delete(f'bot/roles/{role.id}') + await self.bot.api_client.delete(f'bot/roles/{role.id}') + + return diff class UserSyncer(Syncer[User]): """Synchronise the database with users in the cache.""" - async def get_diff(self, guild: Guild) -> Diff[User]: - """Return the users of `guild` with which to synchronise the database.""" - users = await self.api_client.get('bot/users') + name = "user" + + async def _get_diff(self, guild: Guild) -> Diff[User]: + """Return the difference of users between the cache of `guild` and the database.""" + 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. @@ -140,10 +248,24 @@ class UserSyncer(Syncer[User]): return Diff(users_to_create, users_to_update) - async def sync(self, diff: Diff[User]) -> None: - """Synchronise users in the database with the given `diff`.""" + async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> t.Optional[Diff[_T]]: + """ + Synchronise the database with the user cache of `guild` and return the synced difference. + + If the differences between the cache and the database are greater than `MAX_DIFF`, then + a confirmation prompt will be sent to the dev-core channel. The confirmation can be + optionally redirect to `ctx` instead. + + If the sync is not confirmed, None is returned. + """ + diff = await super().sync(guild, ctx) + if diff is None: + return None + for user in diff.created: - await self.api_client.post('bot/users', json={**user._asdict()}) + await self.bot.api_client.post('bot/users', json={**user._asdict()}) for user in diff.updated: - await self.api_client.put(f'bot/users/{user.id}', json={**user._asdict()}) + await self.bot.api_client.put(f'bot/users/{user.id}', json={**user._asdict()}) + + return diff -- cgit v1.2.3 From d059452b94ec8b54bace70852afe1c3b77ce64ff Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 25 Dec 2019 09:40:04 -0800 Subject: Sync: move sync logic into Syncer base class The interface was becoming cumbersome to work with so it was all moved to a single location. Now just calling Syncer.sync() will take care of everything. * Remove Optional type annotation from Diff attributes * _confirm() can edit the original message and use it as the prompt * Calculate the total diff and compare it against the max before sending a confirmation prompt * Remove abort message from sync(); _confirm() will handle that --- bot/cogs/sync/cog.py | 39 +++-------------- bot/cogs/sync/syncers.py | 108 +++++++++++++++++++++-------------------------- 2 files changed, 54 insertions(+), 93 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 1fd39b544..66ffbabf9 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -1,7 +1,7 @@ import logging -from typing import Any, Dict, Optional +from typing import Any, Dict -from discord import Guild, Member, Role, User +from discord import Member, Role, User from discord.ext import commands from discord.ext.commands import Cog, Context @@ -18,8 +18,8 @@ class Sync(Cog): def __init__(self, bot: Bot) -> None: self.bot = bot - self.role_syncer = syncers.RoleSyncer(self.bot.api_client) - self.user_syncer = syncers.UserSyncer(self.bot.api_client) + self.role_syncer = syncers.RoleSyncer(self.bot) + self.user_syncer = syncers.UserSyncer(self.bot) self.bot.loop.create_task(self.sync_guild()) @@ -32,32 +32,7 @@ class Sync(Cog): return for syncer in (self.role_syncer, self.user_syncer): - await self.sync(syncer, guild) - - @staticmethod - async def sync(syncer: syncers.Syncer, guild: Guild, ctx: Optional[Context] = None) -> None: - """Run `syncer` using the cache of the given `guild`.""" - log.info(f"Starting {syncer.name} syncer.") - if ctx: - message = await ctx.send(f"📊 Synchronising {syncer.name}s.") - - diff = await syncer.sync(guild, ctx) - if not diff: - return # Sync was aborted. - - totals = zip(("created", "updated", "deleted"), diff) - results = ", ".join(f"{name} `{len(total)}`" for name, total in totals if total is not None) - - if results: - log.info(f"{syncer.name} syncer finished: {results}.") - if ctx: - await message.edit( - content=f":ok_hand: Synchronisation of {syncer.name}s complete: {results}" - ) - else: - log.warning(f"{syncer.name} syncer aborted!") - if ctx: - await message.edit(content=f":x: Synchronisation of {syncer.name}s aborted!") + await syncer.sync(guild) async def patch_user(self, user_id: int, updated_information: Dict[str, Any]) -> None: """Send a PATCH request to partially update a user in the database.""" @@ -186,10 +161,10 @@ class Sync(Cog): @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.sync(self.role_syncer, ctx.guild, ctx) + 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.sync(self.user_syncer, ctx.guild, ctx) + await self.user_syncer.sync(ctx.guild, ctx) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 7608c6870..7cc518348 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -3,7 +3,7 @@ import logging import typing as t from collections import namedtuple -from discord import Guild, HTTPException +from discord import Guild, HTTPException, Message from discord.ext.commands import Context from bot import constants @@ -22,9 +22,9 @@ _T = t.TypeVar("_T") class Diff(t.NamedTuple, t.Generic[_T]): """The differences between the Discord cache and the contents of the database.""" - created: t.Optional[t.Set[_T]] = None - updated: t.Optional[t.Set[_T]] = None - deleted: t.Optional[t.Set[_T]] = None + created: t.Set[_T] = {} + updated: t.Set[_T] = {} + deleted: t.Set[_T] = {} class Syncer(abc.ABC, t.Generic[_T]): @@ -42,18 +42,22 @@ class Syncer(abc.ABC, t.Generic[_T]): """The name of the syncer; used in output messages and logging.""" raise NotImplementedError - async def _confirm(self, ctx: t.Optional[Context] = None) -> bool: + async def _confirm(self, message: t.Optional[Message] = None) -> bool: """ Send a prompt to confirm or abort a sync using reactions and return True if confirmed. - If no context is given, the prompt is sent to the dev-core channel and mentions the core - developers role. + 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. """ allowed_emoji = (constants.Emojis.check_mark, constants.Emojis.cross_mark) + msg_content = ( + f'Possible cache issue while syncing {self.name}s. ' + f'Found no {self.name}s or more than {self.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 ctx: - mention = f'<@&{constants.Roles.core_developer}>' + if not message: channel = self.bot.get_channel(constants.Channels.devcore) if not channel: @@ -65,24 +69,20 @@ class Syncer(abc.ABC, t.Generic[_T]): f"aborting {self.name} sync." ) return False - else: - mention = ctx.author.mention - channel = ctx.channel - message = await channel.send( - f'{mention} Possible cache issue while syncing {self.name}s. ' - f'Found no {self.name}s or more than {self.MAX_DIFF} {self.name}s were changed. ' - f'React to confirm or abort the sync.' - ) + message = await channel.send(f"<@&{constants.Roles.core_developer}> {msg_content}") + else: + message = await message.edit(content=f"{message.author.mention} {msg_content}") # Add the initial reactions. for emoji in allowed_emoji: await message.add_reaction(emoji) def check(_reaction, user): # noqa: TYP + # Skip author check for auto syncs return ( _reaction.message.id == message.id - and True if not ctx else user == ctx.author # Skip author check for auto syncs + and True if message.author.bot else user == message.author and str(_reaction.emoji) in allowed_emoji ) @@ -98,10 +98,11 @@ class Syncer(abc.ABC, t.Generic[_T]): pass finally: if str(reaction) == constants.Emojis.check_mark: - await channel.send(f':ok_hand: {self.name} sync will proceed.') + await message.edit(content=f':ok_hand: {self.name} sync will proceed.') return True else: - await channel.send(f':x: {self.name} sync aborted!') + log.warning(f"{self.name} syncer aborted!") + await message.edit(content=f':x: {self.name} sync aborted!') return False @abc.abstractmethod @@ -110,23 +111,36 @@ class Syncer(abc.ABC, t.Generic[_T]): raise NotImplementedError @abc.abstractmethod - async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> t.Optional[Diff[_T]]: + async def _sync(self, diff: Diff[_T]) -> None: + """Perform the API calls for synchronisation.""" + raise NotImplementedError + + async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> None: """ - Synchronise the database with the cache of `guild` and return the synced difference. + Synchronise the database with the cache of `guild`. If the differences between the cache and the database are greater than `MAX_DIFF`, then a confirmation prompt will be sent to the dev-core channel. The confirmation can be optionally redirect to `ctx` instead. - - If the sync is not confirmed, None is returned. """ + log.info(f"Starting {self.name} syncer.") + if ctx: + message = await ctx.send(f"📊 Synchronising {self.name}s.") + diff = await self._get_diff(guild) - confirmed = await self._confirm(ctx) + total = sum(map(len, diff)) - if not confirmed: - return None - else: - return diff + if total > self.MAX_DIFF and not await self._confirm(ctx): + return # Sync aborted. + + await self._sync(diff) + + results = ", ".join(f"{name} `{len(total)}`" for name, total in diff._asdict().items()) + log.info(f"{self.name} syncer finished: {results}.") + if ctx: + await message.edit( + content=f":ok_hand: Synchronisation of {self.name}s complete: {results}" + ) class RoleSyncer(Syncer[Role]): @@ -165,20 +179,8 @@ class RoleSyncer(Syncer[Role]): return Diff(roles_to_create, roles_to_update, roles_to_delete) - async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> t.Optional[Diff[Role]]: - """ - Synchronise the database with the role cache of `guild` and return the synced difference. - - If the differences between the cache and the database are greater than `MAX_DIFF`, then - a confirmation prompt will be sent to the dev-core channel. The confirmation can be - optionally redirect to `ctx` instead. - - If the sync is not confirmed, None is returned. - """ - diff = await super().sync(guild, ctx) - if diff is None: - return None - + async def _sync(self, diff: Diff[Role]) -> None: + """Synchronise the database with the role cache of `guild`.""" for role in diff.created: await self.bot.api_client.post('bot/roles', json={**role._asdict()}) @@ -188,8 +190,6 @@ class RoleSyncer(Syncer[Role]): for role in diff.deleted: await self.bot.api_client.delete(f'bot/roles/{role.id}') - return diff - class UserSyncer(Syncer[User]): """Synchronise the database with users in the cache.""" @@ -248,24 +248,10 @@ class UserSyncer(Syncer[User]): return Diff(users_to_create, users_to_update) - async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> t.Optional[Diff[_T]]: - """ - Synchronise the database with the user cache of `guild` and return the synced difference. - - If the differences between the cache and the database are greater than `MAX_DIFF`, then - a confirmation prompt will be sent to the dev-core channel. The confirmation can be - optionally redirect to `ctx` instead. - - If the sync is not confirmed, None is returned. - """ - diff = await super().sync(guild, ctx) - if diff is None: - return None - + async def _sync(self, diff: Diff[User]) -> None: + """Synchronise the database with the user cache of `guild`.""" for user in diff.created: await self.bot.api_client.post('bot/users', json={**user._asdict()}) for user in diff.updated: await self.bot.api_client.put(f'bot/users/{user.id}', json={**user._asdict()}) - - return diff -- cgit v1.2.3 From 617e54e0cd905c834d0153e019951d736c921d5c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 25 Dec 2019 11:11:05 -0800 Subject: Sync: remove generic type from Diff It doesn't play along well with NamedTuple due to metaclass conflicts. The workaround involved created a NamedTuple-only base class, which does work but at the cost of confusing some static type checkers. Since Diff is now an internal data structure, it no longer really needs to have precise type annotations. Therefore, a normal namedtuple is adequate. --- bot/cogs/sync/syncers.py | 29 ++++++++++------------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 7cc518348..394887bab 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -15,19 +15,10 @@ log = logging.getLogger(__name__) # 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', 'avatar_hash', 'roles', 'in_guild')) +Diff = namedtuple('Diff', ('created', 'updated', 'deleted')) -_T = t.TypeVar("_T") - -class Diff(t.NamedTuple, t.Generic[_T]): - """The differences between the Discord cache and the contents of the database.""" - - created: t.Set[_T] = {} - updated: t.Set[_T] = {} - deleted: t.Set[_T] = {} - - -class Syncer(abc.ABC, t.Generic[_T]): +class Syncer(abc.ABC): """Base class for synchronising the database with objects in the Discord cache.""" CONFIRM_TIMEOUT = 60 * 5 # 5 minutes @@ -106,12 +97,12 @@ class Syncer(abc.ABC, t.Generic[_T]): return False @abc.abstractmethod - async def _get_diff(self, guild: Guild) -> Diff[_T]: + async def _get_diff(self, guild: Guild) -> Diff: """Return the difference between the cache of `guild` and the database.""" raise NotImplementedError @abc.abstractmethod - async def _sync(self, diff: Diff[_T]) -> None: + async def _sync(self, diff: Diff) -> None: """Perform the API calls for synchronisation.""" raise NotImplementedError @@ -143,12 +134,12 @@ class Syncer(abc.ABC, t.Generic[_T]): ) -class RoleSyncer(Syncer[Role]): +class RoleSyncer(Syncer): """Synchronise the database with roles in the cache.""" name = "role" - async def _get_diff(self, guild: Guild) -> Diff[Role]: + async def _get_diff(self, guild: Guild) -> Diff: """Return the difference of roles between the cache of `guild` and the database.""" roles = await self.bot.api_client.get('bot/roles') @@ -179,7 +170,7 @@ class RoleSyncer(Syncer[Role]): return Diff(roles_to_create, roles_to_update, roles_to_delete) - async def _sync(self, diff: Diff[Role]) -> None: + async def _sync(self, diff: Diff) -> None: """Synchronise the database with the role cache of `guild`.""" for role in diff.created: await self.bot.api_client.post('bot/roles', json={**role._asdict()}) @@ -191,12 +182,12 @@ class RoleSyncer(Syncer[Role]): await self.bot.api_client.delete(f'bot/roles/{role.id}') -class UserSyncer(Syncer[User]): +class UserSyncer(Syncer): """Synchronise the database with users in the cache.""" name = "user" - async def _get_diff(self, guild: Guild) -> Diff[User]: + async def _get_diff(self, guild: Guild) -> Diff: """Return the difference of users between the cache of `guild` and the database.""" users = await self.bot.api_client.get('bot/users') @@ -248,7 +239,7 @@ class UserSyncer(Syncer[User]): return Diff(users_to_create, users_to_update) - async def _sync(self, diff: Diff[User]) -> None: + async def _sync(self, diff: Diff) -> None: """Synchronise the database with the user cache of `guild`.""" for user in diff.created: await self.bot.api_client.post('bot/users', json={**user._asdict()}) -- cgit v1.2.3 From b9c06880f2f3c2f512a29932acbe3f4cf39f7f0c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 25 Dec 2019 11:12:07 -0800 Subject: Sync: make Role, User, and Diff private --- bot/cogs/sync/syncers.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 394887bab..0a0ce91d0 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -13,9 +13,9 @@ 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', 'avatar_hash', 'roles', 'in_guild')) -Diff = namedtuple('Diff', ('created', 'updated', 'deleted')) +_Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions', 'position')) +_User = namedtuple('User', ('id', 'name', 'discriminator', 'avatar_hash', 'roles', 'in_guild')) +_Diff = namedtuple('Diff', ('created', 'updated', 'deleted')) class Syncer(abc.ABC): @@ -97,12 +97,12 @@ class Syncer(abc.ABC): return False @abc.abstractmethod - async def _get_diff(self, guild: Guild) -> Diff: + async def _get_diff(self, guild: Guild) -> _Diff: """Return the difference between the cache of `guild` and the database.""" raise NotImplementedError @abc.abstractmethod - async def _sync(self, diff: Diff) -> None: + async def _sync(self, diff: _Diff) -> None: """Perform the API calls for synchronisation.""" raise NotImplementedError @@ -139,15 +139,15 @@ class RoleSyncer(Syncer): name = "role" - async def _get_diff(self, guild: Guild) -> Diff: + async def _get_diff(self, guild: Guild) -> _Diff: """Return the difference of roles between the cache of `guild` and the database.""" 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} + db_roles = {_Role(**role_dict) for role_dict in roles} guild_roles = { - Role( + _Role( id=role.id, name=role.name, colour=role.colour.value, @@ -168,9 +168,9 @@ class RoleSyncer(Syncer): 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) + return _Diff(roles_to_create, roles_to_update, roles_to_delete) - async def _sync(self, diff: Diff) -> None: + async def _sync(self, diff: _Diff) -> None: """Synchronise the database with the role cache of `guild`.""" for role in diff.created: await self.bot.api_client.post('bot/roles', json={**role._asdict()}) @@ -187,21 +187,21 @@ class UserSyncer(Syncer): name = "user" - async def _get_diff(self, guild: Guild) -> Diff: + async def _get_diff(self, guild: Guild) -> _Diff: """Return the difference of users between the cache of `guild` and the database.""" 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( + user_dict['id']: _User( roles=tuple(sorted(user_dict.pop('roles'))), **user_dict ) for user_dict in users } guild_users = { - member.id: User( + member.id: _User( id=member.id, name=member.name, discriminator=int(member.discriminator), @@ -237,9 +237,9 @@ class UserSyncer(Syncer): new_user = guild_users[user_id] users_to_create.add(new_user) - return Diff(users_to_create, users_to_update) + return _Diff(users_to_create, users_to_update) - async def _sync(self, diff: Diff) -> None: + async def _sync(self, diff: _Diff) -> None: """Synchronise the database with the user cache of `guild`.""" for user in diff.created: await self.bot.api_client.post('bot/users', json={**user._asdict()}) -- cgit v1.2.3 From 4c9cb1f7a3e8134a11d37f130b115391b3c81b54 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 25 Dec 2019 11:39:26 -0800 Subject: Sync: allow for None values in Diffs --- bot/cogs/sync/syncers.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 0a0ce91d0..8b9fe1ad9 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -119,14 +119,14 @@ class Syncer(abc.ABC): message = await ctx.send(f"📊 Synchronising {self.name}s.") diff = await self._get_diff(guild) - total = sum(map(len, diff)) + totals = {k: len(v) for k, v in diff._asdict().items() if v is not None} - if total > self.MAX_DIFF and not await self._confirm(ctx): + if sum(totals.values()) > self.MAX_DIFF and not await self._confirm(ctx): return # Sync aborted. await self._sync(diff) - results = ", ".join(f"{name} `{len(total)}`" for name, total in diff._asdict().items()) + results = ", ".join(f"{name} `{total}`" for name, total in totals.items()) log.info(f"{self.name} syncer finished: {results}.") if ctx: await message.edit( @@ -237,7 +237,7 @@ class UserSyncer(Syncer): new_user = guild_users[user_id] users_to_create.add(new_user) - return _Diff(users_to_create, users_to_update) + 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`.""" -- cgit v1.2.3 From 7b10c5b81f5016e7e9f3f60da247cf075326d370 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 25 Dec 2019 11:42:02 -0800 Subject: Sync: fix missing await for fetch_channel --- bot/cogs/sync/syncers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 8b9fe1ad9..d9010ce3f 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -53,7 +53,7 @@ class Syncer(abc.ABC): if not channel: try: - channel = self.bot.fetch_channel(constants.Channels.devcore) + channel = await self.bot.fetch_channel(constants.Channels.devcore) except HTTPException: log.exception( f"Failed to fetch channel for sending sync confirmation prompt; " -- cgit v1.2.3 From 919431fddfd2f392cf549177f1d4743c76034951 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 25 Dec 2019 12:11:19 -0800 Subject: Sync: fix passing context instead of message to _confirm() * Mention possibility of timing out as a reason for aborting a sync --- bot/cogs/sync/syncers.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index d9010ce3f..1465730c1 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -92,8 +92,8 @@ class Syncer(abc.ABC): await message.edit(content=f':ok_hand: {self.name} sync will proceed.') return True else: - log.warning(f"{self.name} syncer aborted!") - await message.edit(content=f':x: {self.name} sync aborted!') + log.warning(f"{self.name} syncer aborted or timed out!") + await message.edit(content=f':x: {self.name} sync aborted or timed out!') return False @abc.abstractmethod @@ -115,20 +115,21 @@ class Syncer(abc.ABC): optionally redirect to `ctx` instead. """ log.info(f"Starting {self.name} syncer.") + message = None if ctx: message = await ctx.send(f"📊 Synchronising {self.name}s.") diff = await self._get_diff(guild) totals = {k: len(v) for k, v in diff._asdict().items() if v is not None} - if sum(totals.values()) > self.MAX_DIFF and not await self._confirm(ctx): + if sum(totals.values()) > self.MAX_DIFF and not await self._confirm(message): return # Sync aborted. await self._sync(diff) results = ", ".join(f"{name} `{total}`" for name, total in totals.items()) log.info(f"{self.name} syncer finished: {results}.") - if ctx: + if message: await message.edit( content=f":ok_hand: Synchronisation of {self.name}s complete: {results}" ) -- cgit v1.2.3 From f0c6a34be439788de18872c6edbc1d94256bda14 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 25 Dec 2019 12:13:05 -0800 Subject: Sync: fix overwriting message with None after editing it --- bot/cogs/sync/syncers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 1465730c1..5652872f7 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -63,7 +63,7 @@ class Syncer(abc.ABC): message = await channel.send(f"<@&{constants.Roles.core_developer}> {msg_content}") else: - message = await message.edit(content=f"{message.author.mention} {msg_content}") + await message.edit(content=f"{message.author.mention} {msg_content}") # Add the initial reactions. for emoji in allowed_emoji: -- cgit v1.2.3 From 9e859302ecf2a4d0fd092b21c24ba03401821c0c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 25 Dec 2019 12:15:23 -0800 Subject: Sync: remove author mention from confirm prompt --- bot/cogs/sync/syncers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 5652872f7..ceb046b3e 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -43,7 +43,7 @@ class Syncer(abc.ABC): allowed_emoji = (constants.Emojis.check_mark, constants.Emojis.cross_mark) msg_content = ( f'Possible cache issue while syncing {self.name}s. ' - f'Found no {self.name}s or more than {self.MAX_DIFF} {self.name}s were changed. ' + f'More than {self.MAX_DIFF} {self.name}s were changed. ' f'React to confirm or abort the sync.' ) @@ -63,7 +63,7 @@ class Syncer(abc.ABC): message = await channel.send(f"<@&{constants.Roles.core_developer}> {msg_content}") else: - await message.edit(content=f"{message.author.mention} {msg_content}") + await message.edit(content=msg_content) # Add the initial reactions. for emoji in allowed_emoji: -- cgit v1.2.3 From 9db9fd85e0c12e365c1834812584f4b16862a457 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 25 Dec 2019 12:18:51 -0800 Subject: Sync: fix confirmation reaction check * Ignore bot reactions * Check for core dev role if sync is automatic * Require author as an argument to _confirm() so it can be compared against the reaction author --- bot/cogs/sync/syncers.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index ceb046b3e..2bf551bc7 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -3,7 +3,7 @@ import logging import typing as t from collections import namedtuple -from discord import Guild, HTTPException, Message +from discord import Guild, HTTPException, Member, Message from discord.ext.commands import Context from bot import constants @@ -33,7 +33,7 @@ class Syncer(abc.ABC): """The name of the syncer; used in output messages and logging.""" raise NotImplementedError - async def _confirm(self, message: t.Optional[Message] = None) -> bool: + async def _confirm(self, author: Member, message: t.Optional[Message] = None) -> bool: """ Send a prompt to confirm or abort a sync using reactions and return True if confirmed. @@ -70,10 +70,12 @@ class Syncer(abc.ABC): await message.add_reaction(emoji) def check(_reaction, user): # noqa: TYP - # Skip author check for auto syncs + # For automatic syncs, check for the core dev role instead of an exact author + has_role = any(constants.Roles.core_developer == role.id for role in user.roles) return ( _reaction.message.id == message.id - and True if message.author.bot else user == message.author + and not user.bot + and has_role if author.bot else user == author and str(_reaction.emoji) in allowed_emoji ) @@ -115,14 +117,17 @@ class Syncer(abc.ABC): 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) totals = {k: len(v) for k, v in diff._asdict().items() if v is not None} - if sum(totals.values()) > self.MAX_DIFF and not await self._confirm(message): + if sum(totals.values()) > self.MAX_DIFF and not await self._confirm(author, message): return # Sync aborted. await self._sync(diff) -- cgit v1.2.3 From e1c4471e4497db8918d27195ed4485893bc1b4e9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 25 Dec 2019 13:49:35 -0800 Subject: Sync: add trace and debug logging --- bot/cogs/sync/syncers.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 2bf551bc7..08da569d8 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -40,6 +40,8 @@ class Syncer(abc.ABC): 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. """ + log.trace(f"Sending {self.name} sync confirmation prompt.") + allowed_emoji = (constants.Emojis.check_mark, constants.Emojis.cross_mark) msg_content = ( f'Possible cache issue while syncing {self.name}s. ' @@ -49,9 +51,11 @@ class Syncer(abc.ABC): # 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.devcore) 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.devcore) except HTTPException: @@ -66,6 +70,7 @@ class Syncer(abc.ABC): await message.edit(content=msg_content) # Add the initial reactions. + log.trace(f"Adding reactions to {self.name} syncer confirmation prompt.") for emoji in allowed_emoji: await message.add_reaction(emoji) @@ -81,6 +86,7 @@ class Syncer(abc.ABC): 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=check, @@ -88,9 +94,10 @@ class Syncer(abc.ABC): ) except TimeoutError: # reaction will remain none thus sync will be aborted in the finally block below. - pass + log.debug(f"The {self.name} syncer confirmation prompt timed out.") finally: if str(reaction) == constants.Emojis.check_mark: + log.trace(f"The {self.name} syncer was confirmed.") await message.edit(content=f':ok_hand: {self.name} sync will proceed.') return True else: @@ -127,6 +134,7 @@ class Syncer(abc.ABC): diff = await self._get_diff(guild) totals = {k: len(v) for k, v in diff._asdict().items() if v is not None} + log.trace(f"Determining if confirmation prompt should be sent for {self.name} syncer.") if sum(totals.values()) > self.MAX_DIFF and not await self._confirm(author, message): return # Sync aborted. @@ -147,6 +155,7 @@ class RoleSyncer(Syncer): 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. @@ -178,12 +187,15 @@ class RoleSyncer(Syncer): 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}') @@ -195,6 +207,7 @@ 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') # Pack DB roles and guild roles into one common, hashable format. @@ -247,8 +260,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()}) + log.trace("Syncing updated users...") for user in diff.updated: await self.bot.api_client.put(f'bot/users/{user.id}', json={**user._asdict()}) -- cgit v1.2.3 From bba4319f1e9dbad3c4c0a112252d1a0836f5cbc3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 25 Dec 2019 13:53:08 -0800 Subject: Sync: keep the mention for all edits of the confirmation prompt This makes it clearer to users where the notification came from. --- bot/cogs/sync/syncers.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 08da569d8..2ba9a2a3a 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -43,6 +43,7 @@ class Syncer(abc.ABC): log.trace(f"Sending {self.name} sync confirmation prompt.") allowed_emoji = (constants.Emojis.check_mark, constants.Emojis.cross_mark) + mention = "" msg_content = ( f'Possible cache issue while syncing {self.name}s. ' f'More than {self.MAX_DIFF} {self.name}s were changed. ' @@ -65,7 +66,8 @@ class Syncer(abc.ABC): ) return False - message = await channel.send(f"<@&{constants.Roles.core_developer}> {msg_content}") + mention = f"<@&{constants.Roles.core_developer}> " + message = await channel.send(f"{mention}{msg_content}") else: await message.edit(content=msg_content) @@ -98,11 +100,11 @@ class Syncer(abc.ABC): finally: if str(reaction) == constants.Emojis.check_mark: log.trace(f"The {self.name} syncer was confirmed.") - await message.edit(content=f':ok_hand: {self.name} sync will proceed.') + await message.edit(content=f':ok_hand: {mention}{self.name} sync will proceed.') return True else: - log.warning(f"{self.name} syncer aborted or timed out!") - await message.edit(content=f':x: {self.name} sync aborted or timed out!') + log.warning(f"The {self.name} syncer was aborted or timed out!") + await message.edit(content=f':x: {mention}{self.name} sync aborted or timed out!') return False @abc.abstractmethod -- cgit v1.2.3 From ed8dbbae70ae00c9ee6596dffccfca8f0b78c003 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 26 Dec 2019 20:08:04 -0800 Subject: Sync: split _confirm() into two functions One is responsible for sending the confirmation prompt while the other waits for the reaction. The split allows for the confirmation prompt to be edited with the results of automatic syncs too. --- bot/cogs/sync/syncers.py | 42 ++++++++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 2ba9a2a3a..2376a3f6f 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -21,6 +21,7 @@ _Diff = namedtuple('Diff', ('created', 'updated', 'deleted')) class Syncer(abc.ABC): """Base class for synchronising the database with objects in the Discord cache.""" + _REACTION_EMOJIS = (constants.Emojis.check_mark, constants.Emojis.cross_mark) CONFIRM_TIMEOUT = 60 * 5 # 5 minutes MAX_DIFF = 10 @@ -33,17 +34,16 @@ class Syncer(abc.ABC): """The name of the syncer; used in output messages and logging.""" raise NotImplementedError - async def _confirm(self, author: Member, message: t.Optional[Message] = None) -> bool: + 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 True if confirmed. + 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. + 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.") - allowed_emoji = (constants.Emojis.check_mark, constants.Emojis.cross_mark) - mention = "" msg_content = ( f'Possible cache issue while syncing {self.name}s. ' f'More than {self.MAX_DIFF} {self.name}s were changed. ' @@ -64,7 +64,7 @@ class Syncer(abc.ABC): f"Failed to fetch channel for sending sync confirmation prompt; " f"aborting {self.name} sync." ) - return False + return None mention = f"<@&{constants.Roles.core_developer}> " message = await channel.send(f"{mention}{msg_content}") @@ -73,9 +73,19 @@ class Syncer(abc.ABC): # Add the initial reactions. log.trace(f"Adding reactions to {self.name} syncer confirmation prompt.") - for emoji in allowed_emoji: + for emoji in self._REACTION_EMOJIS: await message.add_reaction(emoji) + return message + + 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. + + If `author` is a bot user, then anyone with the core developers role may react to confirm. + If there is no reaction within `CONFIRM_TIMEOUT` seconds, return False. To acknowledge the + reaction (or lack thereof), `message` will be edited. + """ def check(_reaction, user): # noqa: TYP # For automatic syncs, check for the core dev role instead of an exact author has_role = any(constants.Roles.core_developer == role.id for role in user.roles) @@ -83,9 +93,15 @@ class Syncer(abc.ABC): _reaction.message.id == message.id and not user.bot and has_role if author.bot else user == author - and str(_reaction.emoji) in allowed_emoji + and str(_reaction.emoji) in self._REACTION_EMOJIS ) + # Preserve the core-dev role mention in the message edits so users aren't confused about + # where notifications came from. + mention = "" + if message.role_mentions: + mention = message.role_mentions[0].mention + reaction = None try: log.trace(f"Waiting for a reaction to the {self.name} syncer confirmation prompt.") @@ -137,8 +153,14 @@ class Syncer(abc.ABC): totals = {k: len(v) for k, v in diff._asdict().items() if v is not None} log.trace(f"Determining if confirmation prompt should be sent for {self.name} syncer.") - if sum(totals.values()) > self.MAX_DIFF and not await self._confirm(author, message): - return # Sync aborted. + if sum(totals.values()) > self.MAX_DIFF: + message = await self._send_prompt(message) + if not message: + return # Couldn't get channel. + + confirmed = await self._wait_for_confirmation(author, message) + if not confirmed: + return # Sync aborted. await self._sync(diff) -- cgit v1.2.3 From 144a805704fb9948c15a78cd7e4cbc97aa3a8dd1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 26 Dec 2019 20:40:20 -0800 Subject: Sync: mention core devs when results are shown & fix missing space --- bot/cogs/sync/syncers.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 2376a3f6f..bebea8f19 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -21,7 +21,9 @@ _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_developer}> " _REACTION_EMOJIS = (constants.Emojis.check_mark, constants.Emojis.cross_mark) + CONFIRM_TIMEOUT = 60 * 5 # 5 minutes MAX_DIFF = 10 @@ -66,8 +68,7 @@ class Syncer(abc.ABC): ) return None - mention = f"<@&{constants.Roles.core_developer}> " - message = await channel.send(f"{mention}{msg_content}") + message = await channel.send(f"{self._CORE_DEV_MENTION}{msg_content}") else: await message.edit(content=msg_content) @@ -98,9 +99,7 @@ class Syncer(abc.ABC): # Preserve the core-dev role mention in the message edits so users aren't confused about # where notifications came from. - mention = "" - if message.role_mentions: - mention = message.role_mentions[0].mention + mention = self._CORE_DEV_MENTION if author.bot else "" reaction = None try: @@ -167,8 +166,11 @@ class Syncer(abc.ABC): results = ", ".join(f"{name} `{total}`" for name, total in totals.items()) log.info(f"{self.name} syncer finished: {results}.") if message: + # 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 "" await message.edit( - content=f":ok_hand: Synchronisation of {self.name}s complete: {results}" + content=f":ok_hand: {mention}Synchronisation of {self.name}s complete: {results}" ) -- cgit v1.2.3 From 6c1164fe1bf95d49373722051a00f11e0f17a699 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 27 Dec 2019 14:37:58 -0800 Subject: Sync: handle API errors gracefully The whole sync is aborted when an error is caught for simplicity's sake. The sync message is edited to display the error and the traceback is logged. To distinguish an error from an abort/timeout, the latter now uses a warning emoji while the former uses the red cross. --- bot/cogs/sync/syncers.py | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index bebea8f19..4286609da 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -7,6 +7,7 @@ from discord import Guild, HTTPException, Member, Message from discord.ext.commands import Context from bot import constants +from bot.api import ResponseCodeError from bot.bot import Bot log = logging.getLogger(__name__) @@ -119,7 +120,9 @@ class Syncer(abc.ABC): return True else: log.warning(f"The {self.name} syncer was aborted or timed out!") - await message.edit(content=f':x: {mention}{self.name} sync aborted or timed out!') + await message.edit( + content=f':warning: {mention}{self.name} sync aborted or timed out!' + ) return False @abc.abstractmethod @@ -161,17 +164,25 @@ class Syncer(abc.ABC): if not confirmed: return # Sync aborted. - await self._sync(diff) + # 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}" - results = ", ".join(f"{name} `{total}`" for name, total in totals.items()) - log.info(f"{self.name} syncer finished: {results}.") if message: - # 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 "" - await message.edit( - content=f":ok_hand: {mention}Synchronisation of {self.name}s complete: {results}" - ) + await message.edit(content=content) class RoleSyncer(Syncer): -- cgit v1.2.3 From d9407a56ba34f3a446f3fa583c0c4dec107913dc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 31 Dec 2019 12:19:44 -0800 Subject: Tests: add a MockAPIClient --- tests/helpers.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/helpers.py b/tests/helpers.py index 5df796c23..71b80a223 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -12,6 +12,7 @@ from typing import Any, Iterable, Optional import discord from discord.ext.commands import Context +from bot.api import APIClient from bot.bot import Bot @@ -324,6 +325,22 @@ class MockUser(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): self.mention = f"@{self.name}" +# Create an APIClient instance to get a realistic MagicMock of `bot.api.APIClient` +api_client_instance = APIClient(loop=unittest.mock.MagicMock()) + + +class MockAPIClient(CustomMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock APIClient objects. + + Instances of this class will follow the specifications of `bot.api.APIClient` instances. + For more information, see the `MockGuild` docstring. + """ + + def __init__(self, **kwargs) -> None: + super().__init__(spec_set=api_client_instance, **kwargs) + + # Create a Bot instance to get a realistic MagicMock of `discord.ext.commands.Bot` bot_instance = Bot(command_prefix=unittest.mock.MagicMock()) bot_instance.http_session = None @@ -340,6 +357,7 @@ class MockBot(CustomMockMixin, unittest.mock.MagicMock): def __init__(self, **kwargs) -> None: super().__init__(spec_set=bot_instance, **kwargs) + self.api_client = MockAPIClient() # self.wait_for is *not* a coroutine function, but returns a coroutine nonetheless and # and should therefore be awaited. (The documentation calls it a coroutine as well, which -- cgit v1.2.3 From 43f25fcbbb6cf7b9960317955b57f5e171675d85 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 31 Dec 2019 16:45:57 -0800 Subject: Sync tests: rename the role syncer test case --- tests/bot/cogs/sync/test_roles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index 27ae27639..450a192b7 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -3,7 +3,7 @@ import unittest from bot.cogs.sync.syncers import Role, get_roles_for_sync -class GetRolesForSyncTests(unittest.TestCase): +class RoleSyncerTests(unittest.TestCase): """Tests constructing the roles to synchronize with the site.""" def test_get_roles_for_sync_empty_return_for_equal_roles(self): -- cgit v1.2.3 From c487d80c163682ad8e079257b6bf4bfd11743629 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 31 Dec 2019 16:05:14 -0800 Subject: Sync tests: add fixture to create a guild with roles --- tests/bot/cogs/sync/test_roles.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index 450a192b7..5ae475b2a 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -1,11 +1,31 @@ import unittest -from bot.cogs.sync.syncers import Role, get_roles_for_sync +import discord + +from bot.cogs.sync.syncers import RoleSyncer +from tests import helpers class RoleSyncerTests(unittest.TestCase): """Tests constructing the roles to synchronize with the site.""" + 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: + role.colour = discord.Colour(role.colour) + role.permissions = discord.Permissions(role.permissions) + guild.roles.append(helpers.MockRole(**role)) + + return guild + def test_get_roles_for_sync_empty_return_for_equal_roles(self): """No roles should be synced when no diff is found.""" api_roles = {Role(id=41, name='name', colour=33, permissions=0x8, position=1)} -- cgit v1.2.3 From 28c7ce0465bafc0e07432a94d6f388938a2b3b4d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 31 Dec 2019 16:52:45 -0800 Subject: Sync tests: fix creation of MockRoles Role was being accessed like a class when it is actually a dict. --- tests/bot/cogs/sync/test_roles.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index 5ae475b2a..b1fe500cd 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -20,9 +20,10 @@ class RoleSyncerTests(unittest.TestCase): guild.roles = [] for role in roles: - role.colour = discord.Colour(role.colour) - role.permissions = discord.Permissions(role.permissions) - guild.roles.append(helpers.MockRole(**role)) + 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 -- cgit v1.2.3 From 384a27d18ba258477239daa37569397092e26d76 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 1 Jan 2020 11:44:06 -0800 Subject: Sync tests: test empty diff for identical roles --- tests/bot/cogs/sync/test_roles.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index b1fe500cd..2a60e1fe2 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -1,3 +1,4 @@ +import asyncio import unittest import discord @@ -27,15 +28,17 @@ class RoleSyncerTests(unittest.TestCase): return guild - def test_get_roles_for_sync_empty_return_for_equal_roles(self): - """No roles should be synced when no diff is found.""" - api_roles = {Role(id=41, name='name', colour=33, permissions=0x8, position=1)} - guild_roles = {Role(id=41, name='name', colour=33, permissions=0x8, position=1)} + def test_empty_diff_for_identical_roles(self): + """No differences should be found if the roles in the guild and DB are identical.""" + role = {"id": 41, "name": "name", "colour": 33, "permissions": 0x8, "position": 1} - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - (set(), set(), set()) - ) + self.bot.api_client.get.return_value = [role] + guild = self.get_guild(role) + + actual_diff = asyncio.run(self.syncer._get_diff(guild)) + expected_diff = (set(), set(), set()) + + self.assertEqual(actual_diff, expected_diff) def test_get_roles_for_sync_returns_roles_to_update_with_non_id_diff(self): """Roles to be synced are returned when non-ID attributes differ.""" -- cgit v1.2.3 From 3bafbde6eddbecf3a987b4fe40da00ec79ce4bd4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 1 Jan 2020 16:47:17 -0800 Subject: Sync tests: test diff for updated roles --- tests/bot/cogs/sync/test_roles.py | 43 +++++++++++++++------------------------ 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index 2a60e1fe2..31bf13933 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -3,7 +3,7 @@ import unittest import discord -from bot.cogs.sync.syncers import RoleSyncer +from bot.cogs.sync.syncers import RoleSyncer, _Role from tests import helpers @@ -40,35 +40,24 @@ class RoleSyncerTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - def test_get_roles_for_sync_returns_roles_to_update_with_non_id_diff(self): - """Roles to be synced are returned when non-ID attributes differ.""" - api_roles = {Role(id=41, name='old name', colour=35, permissions=0x8, position=1)} - guild_roles = {Role(id=41, name='new name', colour=33, permissions=0x8, position=2)} + def test_diff_for_updated_roles(self): + """Only updated roles should be added to the updated set of the diff.""" + db_roles = [ + {"id": 41, "name": "old", "colour": 33, "permissions": 0x8, "position": 1}, + {"id": 53, "name": "other", "colour": 55, "permissions": 0, "position": 3}, + ] + guild_roles = [ + {"id": 41, "name": "new", "colour": 33, "permissions": 0x8, "position": 1}, + {"id": 53, "name": "other", "colour": 55, "permissions": 0, "position": 3}, + ] - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - (set(), guild_roles, set()) - ) + self.bot.api_client.get.return_value = db_roles + guild = self.get_guild(*guild_roles) - def test_get_roles_only_returns_roles_that_require_update(self): - """Roles that require an update should be returned as the second tuple element.""" - api_roles = { - Role(id=41, name='old name', colour=33, permissions=0x8, position=1), - Role(id=53, name='other role', colour=55, permissions=0, position=3) - } - guild_roles = { - Role(id=41, name='new name', colour=35, permissions=0x8, position=2), - Role(id=53, name='other role', colour=55, permissions=0, position=3) - } + actual_diff = asyncio.run(self.syncer._get_diff(guild)) + expected_diff = (set(), {_Role(**guild_roles[0])}, set()) - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - ( - set(), - {Role(id=41, name='new name', colour=35, permissions=0x8, position=2)}, - set(), - ) - ) + self.assertEqual(actual_diff, expected_diff) def test_get_roles_returns_new_roles_in_first_tuple_element(self): """Newly created roles are returned as the first tuple element.""" -- cgit v1.2.3 From d9f6fc4c089814992f8c049cb2837e798390ea7d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 1 Jan 2020 17:14:25 -0800 Subject: Sync tests: create a role in setUp to use as a constant --- tests/bot/cogs/sync/test_roles.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index 31bf13933..4eadf8f34 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -13,6 +13,7 @@ class RoleSyncerTests(unittest.TestCase): def setUp(self): self.bot = helpers.MockBot() self.syncer = RoleSyncer(self.bot) + self.constant_role = {"id": 9, "name": "test", "colour": 7, "permissions": 0, "position": 3} @staticmethod def get_guild(*roles): @@ -30,10 +31,8 @@ class RoleSyncerTests(unittest.TestCase): def test_empty_diff_for_identical_roles(self): """No differences should be found if the roles in the guild and DB are identical.""" - role = {"id": 41, "name": "name", "colour": 33, "permissions": 0x8, "position": 1} - - self.bot.api_client.get.return_value = [role] - guild = self.get_guild(role) + self.bot.api_client.get.return_value = [self.constant_role] + guild = self.get_guild(self.constant_role) actual_diff = asyncio.run(self.syncer._get_diff(guild)) expected_diff = (set(), set(), set()) @@ -44,11 +43,11 @@ class RoleSyncerTests(unittest.TestCase): """Only updated roles should be added to the updated set of the diff.""" db_roles = [ {"id": 41, "name": "old", "colour": 33, "permissions": 0x8, "position": 1}, - {"id": 53, "name": "other", "colour": 55, "permissions": 0, "position": 3}, + self.constant_role, ] guild_roles = [ {"id": 41, "name": "new", "colour": 33, "permissions": 0x8, "position": 1}, - {"id": 53, "name": "other", "colour": 55, "permissions": 0, "position": 3}, + self.constant_role, ] self.bot.api_client.get.return_value = db_roles -- cgit v1.2.3 From 99ff41a7abe6b1ccba809654657ba0ba25c43008 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 1 Jan 2020 17:25:02 -0800 Subject: Sync tests: test diff for new roles --- tests/bot/cogs/sync/test_roles.py | 35 +++++++++++++++-------------------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index 4eadf8f34..184050618 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -40,8 +40,8 @@ class RoleSyncerTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) def test_diff_for_updated_roles(self): - """Only updated roles should be added to the updated set of the diff.""" - db_roles = [ + """Only updated roles should be added to the 'updated' set of the diff.""" + self.bot.api_client.get.return_value = [ {"id": 41, "name": "old", "colour": 33, "permissions": 0x8, "position": 1}, self.constant_role, ] @@ -50,7 +50,6 @@ class RoleSyncerTests(unittest.TestCase): self.constant_role, ] - self.bot.api_client.get.return_value = db_roles guild = self.get_guild(*guild_roles) actual_diff = asyncio.run(self.syncer._get_diff(guild)) @@ -58,24 +57,20 @@ class RoleSyncerTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - def test_get_roles_returns_new_roles_in_first_tuple_element(self): - """Newly created roles are returned as the first tuple element.""" - api_roles = { - Role(id=41, name='name', colour=35, permissions=0x8, position=1), - } - guild_roles = { - Role(id=41, name='name', colour=35, permissions=0x8, position=1), - Role(id=53, name='other role', colour=55, permissions=0, position=2) - } + def test_diff_for_new_roles(self): + """Only new roles should be added to the 'created' set of the diff.""" + self.bot.api_client.get.return_value = [self.constant_role] + guild_roles = [ + self.constant_role, + {"id": 41, "name": "new", "colour": 33, "permissions": 0x8, "position": 1} + ] - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - ( - {Role(id=53, name='other role', colour=55, permissions=0, position=2)}, - set(), - set(), - ) - ) + guild = self.get_guild(*guild_roles) + + actual_diff = asyncio.run(self.syncer._get_diff(guild)) + expected_diff = ({_Role(**guild_roles[1])}, set(), set()) + + self.assertEqual(actual_diff, expected_diff) def test_get_roles_returns_roles_to_update_and_new_roles(self): """Newly created and updated roles should be returned together.""" -- cgit v1.2.3 From 51d0e8672a4836b46d99a7a5af42a3d9f363cf57 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 2 Jan 2020 09:17:43 -0800 Subject: Sync tests: test diff for deleted roles --- tests/bot/cogs/sync/test_roles.py | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index 184050618..694ee6276 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -91,24 +91,17 @@ class RoleSyncerTests(unittest.TestCase): ) ) - def test_get_roles_returns_roles_to_delete(self): - """Roles to be deleted should be returned as the third tuple element.""" - api_roles = { - Role(id=41, name='name', colour=35, permissions=0x8, position=1), - Role(id=61, name='to delete', colour=99, permissions=0x9, position=2), - } - guild_roles = { - Role(id=41, name='name', colour=35, permissions=0x8, position=1), - } + def test_diff_for_deleted_roles(self): + """Only deleted roles should be added to the 'deleted' set of the diff.""" + deleted_role = {"id": 61, "name": "delete", "colour": 99, "permissions": 0x9, "position": 2} - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - ( - set(), - set(), - {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)}, - ) - ) + self.bot.api_client.get.return_value = [self.constant_role, deleted_role] + guild = self.get_guild(self.constant_role) + + actual_diff = asyncio.run(self.syncer._get_diff(guild)) + expected_diff = (set(), set(), {_Role(**deleted_role)}) + + self.assertEqual(actual_diff, expected_diff) def test_get_roles_returns_roles_to_delete_update_and_new_roles(self): """When roles were added, updated, and removed, all of them are returned properly.""" -- cgit v1.2.3 From f17a61ac8426bf756ee1f236bbd8f0e33d4932b5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 2 Jan 2020 09:27:00 -0800 Subject: Sync tests: test diff for all 3 role changes simultaneously --- tests/bot/cogs/sync/test_roles.py | 38 +++++++++++++++++--------------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index 694ee6276..ccd617463 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -62,7 +62,7 @@ class RoleSyncerTests(unittest.TestCase): self.bot.api_client.get.return_value = [self.constant_role] guild_roles = [ self.constant_role, - {"id": 41, "name": "new", "colour": 33, "permissions": 0x8, "position": 1} + {"id": 41, "name": "new", "colour": 33, "permissions": 0x8, "position": 1}, ] guild = self.get_guild(*guild_roles) @@ -103,24 +103,20 @@ class RoleSyncerTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - def test_get_roles_returns_roles_to_delete_update_and_new_roles(self): - """When roles were added, updated, and removed, all of them are returned properly.""" - api_roles = { - Role(id=41, name='not changed', colour=35, permissions=0x8, position=1), - Role(id=61, name='to delete', colour=99, permissions=0x9, position=2), - Role(id=71, name='to update', colour=99, permissions=0x9, position=3), - } - guild_roles = { - Role(id=41, name='not changed', colour=35, permissions=0x8, position=1), - Role(id=81, name='to create', colour=99, permissions=0x9, position=4), - Role(id=71, name='updated', colour=101, permissions=0x5, position=3), - } + def test_diff_for_new_updated_and_deleted_roles(self): + """When roles are added, updated, and removed, all of them are returned properly.""" + new = {"id": 41, "name": "new", "colour": 33, "permissions": 0x8, "position": 1} + updated = {"id": 71, "name": "updated", "colour": 101, "permissions": 0x5, "position": 4} + deleted = {"id": 61, "name": "delete", "colour": 99, "permissions": 0x9, "position": 2} - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - ( - {Role(id=81, name='to create', colour=99, permissions=0x9, position=4)}, - {Role(id=71, name='updated', colour=101, permissions=0x5, position=3)}, - {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)}, - ) - ) + self.bot.api_client.get.return_value = [ + self.constant_role, + {"id": 71, "name": "update", "colour": 99, "permissions": 0x9, "position": 4}, + deleted, + ] + guild = self.get_guild(self.constant_role, new, updated) + + actual_diff = asyncio.run(self.syncer._get_diff(guild)) + expected_diff = ({_Role(**new)}, {_Role(**updated)}, {_Role(**deleted)}) + + self.assertEqual(actual_diff, expected_diff) -- cgit v1.2.3 From d212fb724be9ac6ab05671f28113318113a4bbe3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 2 Jan 2020 09:27:51 -0800 Subject: Sync tests: remove diff test for updated and new roles together Redundant since test_diff_for_new_updated_and_deleted_roles tests all 3 types together. --- tests/bot/cogs/sync/test_roles.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index ccd617463..ca9df4305 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -72,25 +72,6 @@ class RoleSyncerTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - def test_get_roles_returns_roles_to_update_and_new_roles(self): - """Newly created and updated roles should be returned together.""" - api_roles = { - Role(id=41, name='old name', colour=35, permissions=0x8, position=1), - } - guild_roles = { - Role(id=41, name='new name', colour=40, permissions=0x16, position=2), - Role(id=53, name='other role', colour=55, permissions=0, position=3) - } - - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - ( - {Role(id=53, name='other role', colour=55, permissions=0, position=3)}, - {Role(id=41, name='new name', colour=40, permissions=0x16, position=2)}, - set(), - ) - ) - def test_diff_for_deleted_roles(self): """Only deleted roles should be added to the 'deleted' set of the diff.""" deleted_role = {"id": 61, "name": "delete", "colour": 99, "permissions": 0x9, "position": 2} -- cgit v1.2.3 From 86cdf82bc7fc96334994f8289f77ea3a6a14828b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 2 Jan 2020 09:31:09 -0800 Subject: Sync tests: remove guild_roles lists and assign roles to variables Makes the creation of the expected diff clearer since the variable has a name compared to accessing some index of a list. --- tests/bot/cogs/sync/test_roles.py | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index ca9df4305..b9a4fe6cd 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -41,34 +41,28 @@ class RoleSyncerTests(unittest.TestCase): def test_diff_for_updated_roles(self): """Only updated roles should be added to the 'updated' set of the diff.""" + updated_role = {"id": 41, "name": "new", "colour": 33, "permissions": 0x8, "position": 1} + self.bot.api_client.get.return_value = [ {"id": 41, "name": "old", "colour": 33, "permissions": 0x8, "position": 1}, self.constant_role, ] - guild_roles = [ - {"id": 41, "name": "new", "colour": 33, "permissions": 0x8, "position": 1}, - self.constant_role, - ] - - guild = self.get_guild(*guild_roles) + guild = self.get_guild(updated_role, self.constant_role) actual_diff = asyncio.run(self.syncer._get_diff(guild)) - expected_diff = (set(), {_Role(**guild_roles[0])}, set()) + expected_diff = (set(), {_Role(**updated_role)}, set()) self.assertEqual(actual_diff, expected_diff) def test_diff_for_new_roles(self): """Only new roles should be added to the 'created' set of the diff.""" - self.bot.api_client.get.return_value = [self.constant_role] - guild_roles = [ - self.constant_role, - {"id": 41, "name": "new", "colour": 33, "permissions": 0x8, "position": 1}, - ] + new_role = {"id": 41, "name": "new", "colour": 33, "permissions": 0x8, "position": 1} - guild = self.get_guild(*guild_roles) + self.bot.api_client.get.return_value = [self.constant_role] + guild = self.get_guild(self.constant_role, new_role) actual_diff = asyncio.run(self.syncer._get_diff(guild)) - expected_diff = ({_Role(**guild_roles[1])}, set(), set()) + expected_diff = ({_Role(**new_role)}, set(), set()) self.assertEqual(actual_diff, expected_diff) -- cgit v1.2.3 From 7c39e44e5c611e01edb0510e23c69dc316ffd184 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 2 Jan 2020 10:16:54 -0800 Subject: Sync tests: create separate role test cases for diff and sync tests --- tests/bot/cogs/sync/test_roles.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index b9a4fe6cd..10818a501 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -7,8 +7,8 @@ from bot.cogs.sync.syncers import RoleSyncer, _Role from tests import helpers -class RoleSyncerTests(unittest.TestCase): - """Tests constructing the roles to synchronize with the site.""" +class RoleSyncerDiffTests(unittest.TestCase): + """Tests for determining differences between roles in the DB and roles in the Guild cache.""" def setUp(self): self.bot = helpers.MockBot() @@ -95,3 +95,11 @@ class RoleSyncerTests(unittest.TestCase): expected_diff = ({_Role(**new)}, {_Role(**updated)}, {_Role(**deleted)}) self.assertEqual(actual_diff, expected_diff) + + +class RoleSyncerSyncTests(unittest.TestCase): + """Tests for the API requests that sync roles.""" + + def setUp(self): + self.bot = helpers.MockBot() + self.syncer = RoleSyncer(self.bot) -- cgit v1.2.3 From dd07547977a4d49d34ebf597d6072d274b2e4feb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 2 Jan 2020 10:22:11 -0800 Subject: Sync tests: test API requests for role syncing --- tests/bot/cogs/sync/test_roles.py | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index 10818a501..719c93d7a 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -3,7 +3,7 @@ import unittest import discord -from bot.cogs.sync.syncers import RoleSyncer, _Role +from bot.cogs.sync.syncers import RoleSyncer, _Diff, _Role from tests import helpers @@ -103,3 +103,36 @@ class RoleSyncerSyncTests(unittest.TestCase): def setUp(self): self.bot = helpers.MockBot() self.syncer = RoleSyncer(self.bot) + + def test_sync_created_role(self): + """Only a POST request should be made with the correct payload.""" + role = {"id": 41, "name": "new", "colour": 33, "permissions": 0x8, "position": 1} + diff = _Diff({_Role(**role)}, set(), set()) + + asyncio.run(self.syncer._sync(diff)) + + self.bot.api_client.post.assert_called_once_with("bot/roles", json=role) + self.bot.api_client.put.assert_not_called() + self.bot.api_client.delete.assert_not_called() + + def test_sync_updated_role(self): + """Only a PUT request should be made with the correct payload.""" + role = {"id": 51, "name": "updated", "colour": 44, "permissions": 0x7, "position": 2} + diff = _Diff(set(), {_Role(**role)}, set()) + + asyncio.run(self.syncer._sync(diff)) + + self.bot.api_client.put.assert_called_once_with(f"bot/roles/{role['id']}", json=role) + self.bot.api_client.post.assert_not_called() + self.bot.api_client.delete.assert_not_called() + + def test_sync_deleted_role(self): + """Only a DELETE request should be made with the correct payload.""" + role = {"id": 61, "name": "deleted", "colour": 55, "permissions": 0x6, "position": 3} + diff = _Diff(set(), set(), {_Role(**role)}) + + asyncio.run(self.syncer._sync(diff)) + + self.bot.api_client.delete.assert_called_once_with(f"bot/roles/{role['id']}") + self.bot.api_client.post.assert_not_called() + self.bot.api_client.put.assert_not_called() -- cgit v1.2.3 From dc3841f5d737a2f697f62970186205c7b12d825e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 2 Jan 2020 12:20:18 -0800 Subject: Sync tests: test syncs with multiple roles --- tests/bot/cogs/sync/test_roles.py | 52 ++++++++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index 719c93d7a..389985bc3 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -1,5 +1,6 @@ import asyncio import unittest +from unittest import mock import discord @@ -104,35 +105,56 @@ class RoleSyncerSyncTests(unittest.TestCase): self.bot = helpers.MockBot() self.syncer = RoleSyncer(self.bot) - def test_sync_created_role(self): - """Only a POST request should be made with the correct payload.""" - role = {"id": 41, "name": "new", "colour": 33, "permissions": 0x8, "position": 1} - diff = _Diff({_Role(**role)}, set(), set()) + def test_sync_created_roles(self): + """Only POST requests should be made with the correct payload.""" + roles = [ + {"id": 111, "name": "new", "colour": 4, "permissions": 0x7, "position": 1}, + {"id": 222, "name": "new2", "colour": 44, "permissions": 0x7, "position": 11}, + ] + role_tuples = {_Role(**role) for role in roles} + diff = _Diff(role_tuples, set(), set()) asyncio.run(self.syncer._sync(diff)) - self.bot.api_client.post.assert_called_once_with("bot/roles", json=role) + 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() - def test_sync_updated_role(self): - """Only a PUT request should be made with the correct payload.""" - role = {"id": 51, "name": "updated", "colour": 44, "permissions": 0x7, "position": 2} - diff = _Diff(set(), {_Role(**role)}, set()) + def test_sync_updated_roles(self): + """Only PUT requests should be made with the correct payload.""" + roles = [ + {"id": 333, "name": "updated", "colour": 5, "permissions": 0x7, "position": 2}, + {"id": 444, "name": "updated2", "colour": 55, "permissions": 0x7, "position": 22}, + ] + role_tuples = {_Role(**role) for role in roles} + diff = _Diff(set(), role_tuples, set()) asyncio.run(self.syncer._sync(diff)) - self.bot.api_client.put.assert_called_once_with(f"bot/roles/{role['id']}", json=role) + 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() - def test_sync_deleted_role(self): - """Only a DELETE request should be made with the correct payload.""" - role = {"id": 61, "name": "deleted", "colour": 55, "permissions": 0x6, "position": 3} - diff = _Diff(set(), set(), {_Role(**role)}) + def test_sync_deleted_roles(self): + """Only DELETE requests should be made with the correct payload.""" + roles = [ + {"id": 555, "name": "deleted", "colour": 6, "permissions": 0x7, "position": 3}, + {"id": 666, "name": "deleted2", "colour": 66, "permissions": 0x7, "position": 33}, + ] + role_tuples = {_Role(**role) for role in roles} + diff = _Diff(set(), set(), role_tuples) asyncio.run(self.syncer._sync(diff)) - self.bot.api_client.delete.assert_called_once_with(f"bot/roles/{role['id']}") + 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() -- cgit v1.2.3 From aa9f5a5eb96cfdf3482f94b0484eed1e54c3b75e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 2 Jan 2020 13:52:38 -0800 Subject: Sync tests: rename user sync test case --- tests/bot/cogs/sync/test_users.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index ccaf67490..509b703ae 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -13,8 +13,8 @@ def fake_user(**kwargs): return User(**kwargs) -class GetUsersForSyncTests(unittest.TestCase): - """Tests constructing the users to synchronize with the site.""" +class UserSyncerDiffTests(unittest.TestCase): + """Tests for determining differences between users in the DB and users in the Guild cache.""" def test_get_users_for_sync_returns_nothing_for_empty_params(self): """When no users are given, none are returned.""" -- cgit v1.2.3 From 7a8c71b7cd5b446188b053aef139255af7bf0154 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 2 Jan 2020 19:31:08 -0800 Subject: Sync tests: add fixture to get a guild with members --- tests/bot/cogs/sync/test_users.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index 509b703ae..83a9cdaf0 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -1,6 +1,7 @@ import unittest -from bot.cogs.sync.syncers import User, get_users_for_sync +from bot.cogs.sync.syncers import UserSyncer +from tests import helpers def fake_user(**kwargs): @@ -16,6 +17,23 @@ def fake_user(**kwargs): class UserSyncerDiffTests(unittest.TestCase): """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: + roles = (helpers.MockRole(id=role_id) for role_id in member.pop("roles")) + mock_member = helpers.MockMember(roles, **member) + guild.members.append(mock_member) + + return guild + def test_get_users_for_sync_returns_nothing_for_empty_params(self): """When no users are given, none are returned.""" self.assertEqual( -- cgit v1.2.3 From f263877518562e33b661e70f6ea3e8f3b1ab914b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 2 Jan 2020 19:34:25 -0800 Subject: Sync tests: test empty diff for no users --- tests/bot/cogs/sync/test_users.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index 83a9cdaf0..b5175a27c 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -1,3 +1,4 @@ +import asyncio import unittest from bot.cogs.sync.syncers import UserSyncer @@ -34,12 +35,14 @@ class UserSyncerDiffTests(unittest.TestCase): return guild - def test_get_users_for_sync_returns_nothing_for_empty_params(self): - """When no users are given, none are returned.""" - self.assertEqual( - get_users_for_sync({}, {}), - (set(), set()) - ) + 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 = asyncio.run(self.syncer._get_diff(guild)) + expected_diff = (set(), set(), None) + + self.assertEqual(actual_diff, expected_diff) def test_get_users_for_sync_returns_nothing_for_equal_users(self): """When no users are updated, none are returned.""" -- cgit v1.2.3 From 7036a9a32651ee0cfb820f994a7332f024169579 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 3 Jan 2020 10:01:48 -0800 Subject: Sync tests: fix fake_user fixture --- tests/bot/cogs/sync/test_users.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index b5175a27c..f3d88c59f 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -6,13 +6,15 @@ from tests import helpers def fake_user(**kwargs): - kwargs.setdefault('id', 43) - kwargs.setdefault('name', 'bob the test man') - kwargs.setdefault('discriminator', 1337) - kwargs.setdefault('avatar_hash', None) - kwargs.setdefault('roles', (666,)) - kwargs.setdefault('in_guild', True) - return 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("avatar_hash", None) + kwargs.setdefault("roles", (666,)) + kwargs.setdefault("in_guild", True) + + return kwargs class UserSyncerDiffTests(unittest.TestCase): -- cgit v1.2.3 From f49d50164cc8afcf1245f3ec47b7963c6874ece6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 3 Jan 2020 10:15:33 -0800 Subject: Sync tests: fix mismatched attributes when creating a mock user --- tests/bot/cogs/sync/test_users.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index f3d88c59f..4c79c51c5 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -32,6 +32,9 @@ class UserSyncerDiffTests(unittest.TestCase): for member in members: roles = (helpers.MockRole(id=role_id) for role_id in member.pop("roles")) + member["avatar"] = member.pop("avatar_hash") + del member["in_guild"] + mock_member = helpers.MockMember(roles, **member) guild.members.append(mock_member) -- cgit v1.2.3 From eab415b61122de4c039b229390e1d6c180d101da Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 3 Jan 2020 10:53:32 -0800 Subject: Sync tests: work around @everyone role being added by MockMember --- tests/bot/cogs/sync/test_users.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index 4c79c51c5..3dd2942b5 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -31,11 +31,12 @@ class UserSyncerDiffTests(unittest.TestCase): guild.members = [] for member in members: - roles = (helpers.MockRole(id=role_id) for role_id in member.pop("roles")) member["avatar"] = member.pop("avatar_hash") del member["in_guild"] - mock_member = helpers.MockMember(roles, **member) + 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 -- cgit v1.2.3 From 4912e94e3079b01b9481dee785c0b7f2552f7a1b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 3 Jan 2020 10:53:45 -0800 Subject: Sync tests: test empty diff for identical users --- tests/bot/cogs/sync/test_users.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index 3dd2942b5..7a4a85c96 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -50,15 +50,15 @@ class UserSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - def test_get_users_for_sync_returns_nothing_for_equal_users(self): - """When no users are updated, none are returned.""" - api_users = {43: fake_user()} - guild_users = {43: fake_user()} + 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()) - self.assertEqual( - get_users_for_sync(guild_users, api_users), - (set(), set()) - ) + actual_diff = asyncio.run(self.syncer._get_diff(guild)) + expected_diff = (set(), set(), None) + + self.assertEqual(actual_diff, expected_diff) def test_get_users_for_sync_returns_users_to_update_on_non_id_field_diff(self): """When a non-ID-field differs, the user to update is returned.""" -- cgit v1.2.3 From c53cc07217faa15f56c60c3b36aefbb7676e6011 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 3 Jan 2020 11:20:07 -0800 Subject: Sync tests: fix get_guild modifying the original member dicts --- tests/bot/cogs/sync/test_users.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index 7a4a85c96..0d00e6970 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -31,6 +31,7 @@ class UserSyncerDiffTests(unittest.TestCase): guild.members = [] for member in members: + member = member.copy() member["avatar"] = member.pop("avatar_hash") del member["in_guild"] -- cgit v1.2.3 From e74d360e3834511ffa2fb93f1146cda664a403a5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 3 Jan 2020 11:20:33 -0800 Subject: Sync tests: test diff for updated users --- tests/bot/cogs/sync/test_users.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index 0d00e6970..f1084fa98 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -1,7 +1,7 @@ import asyncio import unittest -from bot.cogs.sync.syncers import UserSyncer +from bot.cogs.sync.syncers import UserSyncer, _User from tests import helpers @@ -61,15 +61,17 @@ class UserSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - def test_get_users_for_sync_returns_users_to_update_on_non_id_field_diff(self): - """When a non-ID-field differs, the user to update is returned.""" - api_users = {43: fake_user()} - guild_users = {43: fake_user(name='new fancy name')} + 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.assertEqual( - get_users_for_sync(guild_users, api_users), - (set(), {fake_user(name='new fancy name')}) - ) + 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 = asyncio.run(self.syncer._get_diff(guild)) + expected_diff = (set(), {_User(**updated_user)}, None) + + self.assertEqual(actual_diff, expected_diff) def test_get_users_for_sync_returns_users_to_create_with_new_ids_on_guild(self): """When new users join the guild, they are returned as the first tuple element.""" -- cgit v1.2.3 From 30ebb0184d12000db3ae5f276395fecd52d5dfa5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 3 Jan 2020 11:22:46 -0800 Subject: Sync tests: test diff for new users --- tests/bot/cogs/sync/test_users.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index f1084fa98..c8ce7c04d 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -73,15 +73,17 @@ class UserSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - def test_get_users_for_sync_returns_users_to_create_with_new_ids_on_guild(self): - """When new users join the guild, they are returned as the first tuple element.""" - api_users = {43: fake_user()} - guild_users = {43: fake_user(), 63: fake_user(id=63)} + 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.assertEqual( - get_users_for_sync(guild_users, api_users), - ({fake_user(id=63)}, set()) - ) + self.bot.api_client.get.return_value = [fake_user()] + guild = self.get_guild(fake_user(), new_user) + + actual_diff = asyncio.run(self.syncer._get_diff(guild)) + expected_diff = ({_User(**new_user)}, set(), None) + + self.assertEqual(actual_diff, expected_diff) def test_get_users_for_sync_updates_in_guild_field_on_user_leave(self): """When a user leaves the guild, the `in_guild` flag is updated to `False`.""" -- cgit v1.2.3 From 16f7eda6005b974ee2bc77f0440e05afad46c8e7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 3 Jan 2020 11:34:34 -0800 Subject: Sync tests: test diff for users which leave the guild --- tests/bot/cogs/sync/test_users.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index c8ce7c04d..faa5918df 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -85,15 +85,17 @@ class UserSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - def test_get_users_for_sync_updates_in_guild_field_on_user_leave(self): + 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`.""" - api_users = {43: fake_user(), 63: fake_user(id=63)} - guild_users = {43: fake_user()} + leaving_user = fake_user(id=63, in_guild=False) - self.assertEqual( - get_users_for_sync(guild_users, api_users), - (set(), {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 = asyncio.run(self.syncer._get_diff(guild)) + expected_diff = (set(), {_User(**leaving_user)}, None) + + self.assertEqual(actual_diff, expected_diff) def test_get_users_for_sync_updates_and_creates_users_as_needed(self): """When one user left and another one was updated, both are returned.""" -- cgit v1.2.3 From 6401306228526250092fece786640be281eac812 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 3 Jan 2020 11:43:35 -0800 Subject: Sync tests: test diff for all 3 changes simultaneously --- tests/bot/cogs/sync/test_users.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index faa5918df..ff863a929 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -97,15 +97,19 @@ class UserSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - def test_get_users_for_sync_updates_and_creates_users_as_needed(self): - """When one user left and another one was updated, both are returned.""" - api_users = {43: fake_user()} - guild_users = {63: fake_user(id=63)} + 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.assertEqual( - get_users_for_sync(guild_users, api_users), - ({fake_user(id=63)}, {fake_user(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 = asyncio.run(self.syncer._get_diff(guild)) + expected_diff = ({_User(**new_user)}, {_User(**updated_user), _User(**leaving_user)}, None) + + self.assertEqual(actual_diff, expected_diff) def test_get_users_for_sync_does_not_duplicate_update_users(self): """When the API knows a user the guild doesn't, nothing is performed.""" -- cgit v1.2.3 From 01d7b53180864b1e47ebc8c831a706dc1a3c0d79 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 3 Jan 2020 11:45:47 -0800 Subject: Sync tests: test diff is empty when DB has a user not in the guild --- tests/bot/cogs/sync/test_users.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index ff863a929..dfb9ac405 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -111,12 +111,12 @@ class UserSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - def test_get_users_for_sync_does_not_duplicate_update_users(self): - """When the API knows a user the guild doesn't, nothing is performed.""" - api_users = {43: fake_user(in_guild=False)} - guild_users = {} - - self.assertEqual( - get_users_for_sync(guild_users, api_users), - (set(), set()) - ) + 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 = asyncio.run(self.syncer._get_diff(guild)) + expected_diff = (set(), set(), None) + + self.assertEqual(actual_diff, expected_diff) -- cgit v1.2.3 From 155c4c7a1bb73ef42cf19ccacc612c7a5bc17201 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 3 Jan 2020 11:54:39 -0800 Subject: Sync tests: add tests for API requests for syncing users --- tests/bot/cogs/sync/test_users.py | 41 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index dfb9ac405..7fc1b400f 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -1,7 +1,8 @@ import asyncio import unittest +from unittest import mock -from bot.cogs.sync.syncers import UserSyncer, _User +from bot.cogs.sync.syncers import UserSyncer, _Diff, _User from tests import helpers @@ -120,3 +121,41 @@ class UserSyncerDiffTests(unittest.TestCase): expected_diff = (set(), set(), None) self.assertEqual(actual_diff, expected_diff) + + +class UserSyncerSyncTests(unittest.TestCase): + """Tests for the API requests that sync roles.""" + + def setUp(self): + self.bot = helpers.MockBot() + self.syncer = UserSyncer(self.bot) + + 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) + asyncio.run(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() + + 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) + asyncio.run(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() -- cgit v1.2.3 From b5febafba40e3de655b723eed274ac94919a395e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 3 Jan 2020 12:01:47 -0800 Subject: Sync tests: create and use a fake_role fixture --- tests/bot/cogs/sync/test_roles.py | 64 +++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index 389985bc3..8324b99cd 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -8,13 +8,23 @@ from bot.cogs.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.TestCase): """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) - self.constant_role = {"id": 9, "name": "test", "colour": 7, "permissions": 0, "position": 3} @staticmethod def get_guild(*roles): @@ -32,8 +42,8 @@ class RoleSyncerDiffTests(unittest.TestCase): 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 = [self.constant_role] - guild = self.get_guild(self.constant_role) + self.bot.api_client.get.return_value = [fake_role()] + guild = self.get_guild(fake_role()) actual_diff = asyncio.run(self.syncer._get_diff(guild)) expected_diff = (set(), set(), set()) @@ -42,13 +52,10 @@ class RoleSyncerDiffTests(unittest.TestCase): def test_diff_for_updated_roles(self): """Only updated roles should be added to the 'updated' set of the diff.""" - updated_role = {"id": 41, "name": "new", "colour": 33, "permissions": 0x8, "position": 1} + updated_role = fake_role(id=41, name="new") - self.bot.api_client.get.return_value = [ - {"id": 41, "name": "old", "colour": 33, "permissions": 0x8, "position": 1}, - self.constant_role, - ] - guild = self.get_guild(updated_role, self.constant_role) + 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 = asyncio.run(self.syncer._get_diff(guild)) expected_diff = (set(), {_Role(**updated_role)}, set()) @@ -57,10 +64,10 @@ class RoleSyncerDiffTests(unittest.TestCase): def test_diff_for_new_roles(self): """Only new roles should be added to the 'created' set of the diff.""" - new_role = {"id": 41, "name": "new", "colour": 33, "permissions": 0x8, "position": 1} + new_role = fake_role(id=41, name="new") - self.bot.api_client.get.return_value = [self.constant_role] - guild = self.get_guild(self.constant_role, new_role) + self.bot.api_client.get.return_value = [fake_role()] + guild = self.get_guild(fake_role(), new_role) actual_diff = asyncio.run(self.syncer._get_diff(guild)) expected_diff = ({_Role(**new_role)}, set(), set()) @@ -69,10 +76,10 @@ class RoleSyncerDiffTests(unittest.TestCase): def test_diff_for_deleted_roles(self): """Only deleted roles should be added to the 'deleted' set of the diff.""" - deleted_role = {"id": 61, "name": "delete", "colour": 99, "permissions": 0x9, "position": 2} + deleted_role = fake_role(id=61, name="deleted") - self.bot.api_client.get.return_value = [self.constant_role, deleted_role] - guild = self.get_guild(self.constant_role) + self.bot.api_client.get.return_value = [fake_role(), deleted_role] + guild = self.get_guild(fake_role()) actual_diff = asyncio.run(self.syncer._get_diff(guild)) expected_diff = (set(), set(), {_Role(**deleted_role)}) @@ -81,16 +88,16 @@ class RoleSyncerDiffTests(unittest.TestCase): def test_diff_for_new_updated_and_deleted_roles(self): """When roles are added, updated, and removed, all of them are returned properly.""" - new = {"id": 41, "name": "new", "colour": 33, "permissions": 0x8, "position": 1} - updated = {"id": 71, "name": "updated", "colour": 101, "permissions": 0x5, "position": 4} - deleted = {"id": 61, "name": "delete", "colour": 99, "permissions": 0x9, "position": 2} + 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 = [ - self.constant_role, - {"id": 71, "name": "update", "colour": 99, "permissions": 0x9, "position": 4}, + fake_role(), + fake_role(id=71, name="updated name"), deleted, ] - guild = self.get_guild(self.constant_role, new, updated) + guild = self.get_guild(fake_role(), new, updated) actual_diff = asyncio.run(self.syncer._get_diff(guild)) expected_diff = ({_Role(**new)}, {_Role(**updated)}, {_Role(**deleted)}) @@ -107,10 +114,7 @@ class RoleSyncerSyncTests(unittest.TestCase): def test_sync_created_roles(self): """Only POST requests should be made with the correct payload.""" - roles = [ - {"id": 111, "name": "new", "colour": 4, "permissions": 0x7, "position": 1}, - {"id": 222, "name": "new2", "colour": 44, "permissions": 0x7, "position": 11}, - ] + roles = [fake_role(id=111), fake_role(id=222)] role_tuples = {_Role(**role) for role in roles} diff = _Diff(role_tuples, set(), set()) @@ -125,10 +129,7 @@ class RoleSyncerSyncTests(unittest.TestCase): def test_sync_updated_roles(self): """Only PUT requests should be made with the correct payload.""" - roles = [ - {"id": 333, "name": "updated", "colour": 5, "permissions": 0x7, "position": 2}, - {"id": 444, "name": "updated2", "colour": 55, "permissions": 0x7, "position": 22}, - ] + roles = [fake_role(id=111), fake_role(id=222)] role_tuples = {_Role(**role) for role in roles} diff = _Diff(set(), role_tuples, set()) @@ -143,10 +144,7 @@ class RoleSyncerSyncTests(unittest.TestCase): def test_sync_deleted_roles(self): """Only DELETE requests should be made with the correct payload.""" - roles = [ - {"id": 555, "name": "deleted", "colour": 6, "permissions": 0x7, "position": 3}, - {"id": 666, "name": "deleted2", "colour": 66, "permissions": 0x7, "position": 33}, - ] + roles = [fake_role(id=111), fake_role(id=222)] role_tuples = {_Role(**role) for role in roles} diff = _Diff(set(), set(), role_tuples) -- cgit v1.2.3 From 396d2b393a255580ea23c3cc4abb4bdb1e84ea7d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 3 Jan 2020 12:08:44 -0800 Subject: Sync tests: fix docstring for UserSyncerSyncTests --- tests/bot/cogs/sync/test_users.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index 7fc1b400f..e9f9db2ea 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -124,7 +124,7 @@ class UserSyncerDiffTests(unittest.TestCase): class UserSyncerSyncTests(unittest.TestCase): - """Tests for the API requests that sync roles.""" + """Tests for the API requests that sync users.""" def setUp(self): self.bot = helpers.MockBot() -- cgit v1.2.3 From f6c78b63bccc36526d8ee8072a27e0678db0781a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 3 Jan 2020 12:12:00 -0800 Subject: Sync tests: fix wait_until_ready in duck pond tests --- tests/bot/cogs/test_duck_pond.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index d07b2bce1..5b0a3b8c3 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -54,7 +54,7 @@ class DuckPondTests(base.LoggingTestCase): asyncio.run(self.cog.fetch_webhook()) - self.bot.wait_until_ready.assert_called_once() + 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") @@ -67,7 +67,7 @@ class DuckPondTests(base.LoggingTestCase): with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: asyncio.run(self.cog.fetch_webhook()) - self.bot.wait_until_ready.assert_called_once() + 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) -- cgit v1.2.3 From 5024a75004f8d9f4726017af74cace6c1ab6c501 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 4 Jan 2020 10:25:33 -0800 Subject: Sync tests: test instantiation fails without abstract methods --- tests/bot/cogs/sync/test_base.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 tests/bot/cogs/sync/test_base.py diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py new file mode 100644 index 000000000..79ec86fee --- /dev/null +++ b/tests/bot/cogs/sync/test_base.py @@ -0,0 +1,17 @@ +import unittest + +from bot.cogs.sync.syncers import Syncer +from tests import helpers + + +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) + -- cgit v1.2.3 From c4caf865ce677a8d1d827cbd1107338c251ff90b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 4 Jan 2020 10:27:48 -0800 Subject: Sync tests: create a Syncer subclass for testing --- tests/bot/cogs/sync/test_base.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 79ec86fee..d38c90410 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -4,11 +4,20 @@ from bot.cogs.sync.syncers import Syncer from tests import helpers +class TestSyncer(Syncer): + """Syncer subclass with mocks for abstract methods for testing purposes.""" + + name = "test" + _get_diff = helpers.AsyncMock() + _sync = helpers.AsyncMock() + + class SyncerBaseTests(unittest.TestCase): """Tests for the syncer base class.""" def setUp(self): self.bot = helpers.MockBot() + self.syncer = TestSyncer(self.bot) def test_instantiation_fails_without_abstract_methods(self): """The class must have abstract methods implemented.""" -- cgit v1.2.3 From 113029aae7625118ac1a5491652f3960172a3605 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 5 Jan 2020 09:41:28 -0800 Subject: Sync tests: test that _send_prompt edits message contents --- tests/bot/cogs/sync/test_base.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index d38c90410..048d6c533 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -1,3 +1,4 @@ +import asyncio import unittest from bot.cogs.sync.syncers import Syncer @@ -24,3 +25,10 @@ class SyncerBaseTests(unittest.TestCase): with self.assertRaisesRegex(TypeError, "Can't instantiate abstract class"): Syncer(self.bot) + def test_send_prompt_edits_message_content(self): + """The contents of the given message should be edited to display the prompt.""" + msg = helpers.MockMessage() + asyncio.run(self.syncer._send_prompt(msg)) + + msg.edit.assert_called_once() + self.assertIn("content", msg.edit.call_args[1]) -- cgit v1.2.3 From e6bb9a79faad03ea7c3a373af84f707722da106f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 5 Jan 2020 10:43:34 -0800 Subject: Sync tests: test that _send_prompt gets channel from cache --- tests/bot/cogs/sync/test_base.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 048d6c533..9b177f25c 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -2,6 +2,7 @@ import asyncio import unittest from bot.cogs.sync.syncers import Syncer +from bot import constants from tests import helpers @@ -32,3 +33,13 @@ class SyncerBaseTests(unittest.TestCase): msg.edit.assert_called_once() self.assertIn("content", msg.edit.call_args[1]) + + def test_send_prompt_gets_channel_from_cache(self): + """The dev-core channel should be retrieved from cache if an extant message isn't given.""" + mock_channel = helpers.MockTextChannel() + mock_channel.send.return_value = helpers.MockMessage() + self.bot.get_channel.return_value = mock_channel + + asyncio.run(self.syncer._send_prompt()) + + self.bot.get_channel.assert_called_once_with(constants.Channels.devcore) -- cgit v1.2.3 From a6f01dbd55aef97a39f615348ea22b62a59f2c70 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 5 Jan 2020 10:44:14 -0800 Subject: Sync tests: test _send_prompt fetches channel on a cache miss --- tests/bot/cogs/sync/test_base.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 9b177f25c..c18fa5fbb 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -43,3 +43,14 @@ class SyncerBaseTests(unittest.TestCase): asyncio.run(self.syncer._send_prompt()) self.bot.get_channel.assert_called_once_with(constants.Channels.devcore) + + def test_send_prompt_fetches_channel_if_cache_miss(self): + """The dev-core channel should be fetched with an API call if it's not in the cache.""" + self.bot.get_channel.return_value = None + mock_channel = helpers.MockTextChannel() + mock_channel.send.return_value = helpers.MockMessage() + self.bot.fetch_channel.return_value = mock_channel + + asyncio.run(self.syncer._send_prompt()) + + self.bot.fetch_channel.assert_called_once_with(constants.Channels.devcore) -- cgit v1.2.3 From 6f116956395fa1b48233a3014d215a3704b929ed Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 5 Jan 2020 10:44:31 -0800 Subject: Sync tests: test _send_prompt returns None if channel fetch fails --- tests/bot/cogs/sync/test_base.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index c18fa5fbb..8eecea53f 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -1,5 +1,8 @@ import asyncio import unittest +from unittest import mock + +import discord from bot.cogs.sync.syncers import Syncer from bot import constants @@ -54,3 +57,12 @@ class SyncerBaseTests(unittest.TestCase): asyncio.run(self.syncer._send_prompt()) self.bot.fetch_channel.assert_called_once_with(constants.Channels.devcore) + + 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 = asyncio.run(self.syncer._send_prompt()) + + self.assertIsNone(ret_val) -- cgit v1.2.3 From d57db0b39b52b4660986e90d308434c823428b71 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 5 Jan 2020 11:06:44 -0800 Subject: Sync tests: test _send_prompt sends a new message if one isn't given --- tests/bot/cogs/sync/test_base.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 8eecea53f..f4ea33823 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -66,3 +66,14 @@ class SyncerBaseTests(unittest.TestCase): ret_val = asyncio.run(self.syncer._send_prompt()) self.assertIsNone(ret_val) + + def test_send_prompt_sends_new_message_if_not_given(self): + """A new message that mentions core devs should be sent if an extant message isn't given.""" + mock_channel = helpers.MockTextChannel() + mock_channel.send.return_value = helpers.MockMessage() + self.bot.get_channel.return_value = mock_channel + + asyncio.run(self.syncer._send_prompt()) + + mock_channel.send.assert_called_once() + self.assertIn(self.syncer._CORE_DEV_MENTION, mock_channel.send.call_args[0][0]) -- cgit v1.2.3 From 3298312ad182dd1a8a5c9596d7bdc1d6f4905ebf Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 5 Jan 2020 11:23:55 -0800 Subject: Sync tests: test _send_prompt adds reactions --- tests/bot/cogs/sync/test_base.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index f4ea33823..e509b3c98 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -77,3 +77,11 @@ class SyncerBaseTests(unittest.TestCase): mock_channel.send.assert_called_once() self.assertIn(self.syncer._CORE_DEV_MENTION, mock_channel.send.call_args[0][0]) + + def test_send_prompt_adds_reactions(self): + """The message should have reactions for confirmation added.""" + msg = helpers.MockMessage() + asyncio.run(self.syncer._send_prompt(msg)) + + calls = [mock.call(emoji) for emoji in self.syncer._REACTION_EMOJIS] + msg.add_reaction.assert_has_calls(calls) -- cgit v1.2.3 From 0fdd675f5bc85a20268e257e073d9605126ee322 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 7 Jan 2020 09:56:34 -0800 Subject: Sync tests: add fixtures to mock dev core channel get and fetch --- tests/bot/cogs/sync/test_base.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index e509b3c98..2c6857246 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -24,6 +24,27 @@ class SyncerBaseTests(unittest.TestCase): self.bot = helpers.MockBot() self.syncer = TestSyncer(self.bot) + def mock_dev_core_channel(self): + """Fixture to return a mock channel and message for when `get_channel` is used.""" + 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_dev_core_channel_cache_miss(self): + """Fixture to return a mock channel and message for when `fetch_channel` is used.""" + 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 + def test_instantiation_fails_without_abstract_methods(self): """The class must have abstract methods implemented.""" with self.assertRaisesRegex(TypeError, "Can't instantiate abstract class"): -- cgit v1.2.3 From 7d3b46741cfd12d2f8cc40107464f7b3210b9af5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 7 Jan 2020 10:39:20 -0800 Subject: Sync tests: reset mocks in channel fixtures --- tests/bot/cogs/sync/test_base.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 2c6857246..ff67eb334 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -26,6 +26,8 @@ class SyncerBaseTests(unittest.TestCase): def mock_dev_core_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() @@ -36,6 +38,8 @@ class SyncerBaseTests(unittest.TestCase): def mock_dev_core_channel_cache_miss(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() -- cgit v1.2.3 From 7215a9483cb9ebae89d147f950dd62996d86beeb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 7 Jan 2020 10:45:02 -0800 Subject: Sync tests: rename channel fixtures --- tests/bot/cogs/sync/test_base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index ff67eb334..1d61f8cb2 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -24,7 +24,7 @@ class SyncerBaseTests(unittest.TestCase): self.bot = helpers.MockBot() self.syncer = TestSyncer(self.bot) - def mock_dev_core_channel(self): + def mock_get_channel(self): """Fixture to return a mock channel and message for when `get_channel` is used.""" self.bot.reset_mock() @@ -36,7 +36,7 @@ class SyncerBaseTests(unittest.TestCase): return mock_channel, mock_message - def mock_dev_core_channel_cache_miss(self): + def mock_fetch_channel(self): """Fixture to return a mock channel and message for when `fetch_channel` is used.""" self.bot.reset_mock() -- cgit v1.2.3 From 1cef637f4d53ba1a093403f4e237e6004330cc1d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 7 Jan 2020 10:46:16 -0800 Subject: Sync tests: use channel fixtures with subtests * Merge test_send_prompt_fetches_channel_if_cache_miss into test_send_prompt_gets_channel_from_cache * Rename test_send_prompt_gets_channel_from_cache * Test test_send_prompt_sends_new_message_if_not_given with fetch_channel too --- tests/bot/cogs/sync/test_base.py | 42 ++++++++++++++++------------------------ 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 1d61f8cb2..d46965738 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -62,26 +62,19 @@ class SyncerBaseTests(unittest.TestCase): msg.edit.assert_called_once() self.assertIn("content", msg.edit.call_args[1]) - def test_send_prompt_gets_channel_from_cache(self): - """The dev-core channel should be retrieved from cache if an extant message isn't given.""" - mock_channel = helpers.MockTextChannel() - mock_channel.send.return_value = helpers.MockMessage() - self.bot.get_channel.return_value = mock_channel - - asyncio.run(self.syncer._send_prompt()) + 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), + ) - self.bot.get_channel.assert_called_once_with(constants.Channels.devcore) + for method, mock_ in subtests: + with self.subTest(method=method, msg=mock_.__name__): + mock_() + asyncio.run(self.syncer._send_prompt()) - def test_send_prompt_fetches_channel_if_cache_miss(self): - """The dev-core channel should be fetched with an API call if it's not in the cache.""" - self.bot.get_channel.return_value = None - mock_channel = helpers.MockTextChannel() - mock_channel.send.return_value = helpers.MockMessage() - self.bot.fetch_channel.return_value = mock_channel - - asyncio.run(self.syncer._send_prompt()) - - self.bot.fetch_channel.assert_called_once_with(constants.Channels.devcore) + method.assert_called_once_with(constants.Channels.devcore) def test_send_prompt_returns_None_if_channel_fetch_fails(self): """None should be returned if there's an HTTPException when fetching the channel.""" @@ -94,14 +87,13 @@ class SyncerBaseTests(unittest.TestCase): def test_send_prompt_sends_new_message_if_not_given(self): """A new message that mentions core devs should be sent if an extant message isn't given.""" - mock_channel = helpers.MockTextChannel() - mock_channel.send.return_value = helpers.MockMessage() - self.bot.get_channel.return_value = mock_channel - - asyncio.run(self.syncer._send_prompt()) + for mock_ in (self.mock_get_channel, self.mock_fetch_channel): + with self.subTest(msg=mock_.__name__): + mock_channel, _ = mock_() + asyncio.run(self.syncer._send_prompt()) - mock_channel.send.assert_called_once() - self.assertIn(self.syncer._CORE_DEV_MENTION, mock_channel.send.call_args[0][0]) + mock_channel.send.assert_called_once() + self.assertIn(self.syncer._CORE_DEV_MENTION, mock_channel.send.call_args[0][0]) def test_send_prompt_adds_reactions(self): """The message should have reactions for confirmation added.""" -- cgit v1.2.3 From cc8ecb9fd52b24e323c4e6f5ce8a2ddcc8d31777 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 7 Jan 2020 11:18:35 -0800 Subject: Sync tests: use channel fixtures with subtests in add reaction test --- tests/bot/cogs/sync/test_base.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index d46965738..e0a3f4127 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -97,8 +97,19 @@ class SyncerBaseTests(unittest.TestCase): def test_send_prompt_adds_reactions(self): """The message should have reactions for confirmation added.""" - msg = helpers.MockMessage() - asyncio.run(self.syncer._send_prompt(msg)) + 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_() + asyncio.run(self.syncer._send_prompt(message_arg)) - calls = [mock.call(emoji) for emoji in self.syncer._REACTION_EMOJIS] - msg.add_reaction.assert_has_calls(calls) + calls = [mock.call(emoji) for emoji in self.syncer._REACTION_EMOJIS] + mock_message.add_reaction.assert_has_calls(calls) -- cgit v1.2.3 From 04dbf347e08d4e2a3690e59a537ab73544c82be6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 7 Jan 2020 11:34:05 -0800 Subject: Sync tests: test the return value of _send_prompt --- tests/bot/cogs/sync/test_base.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index e0a3f4127..4c3eae1b3 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -54,13 +54,14 @@ class SyncerBaseTests(unittest.TestCase): with self.assertRaisesRegex(TypeError, "Can't instantiate abstract class"): Syncer(self.bot) - def test_send_prompt_edits_message_content(self): - """The contents of the given message should be edited to display the prompt.""" + 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() - asyncio.run(self.syncer._send_prompt(msg)) + ret_val = asyncio.run(self.syncer._send_prompt(msg)) msg.edit.assert_called_once() self.assertIn("content", msg.edit.call_args[1]) + self.assertEqual(ret_val, msg) def test_send_prompt_gets_dev_core_channel(self): """The dev-core channel should be retrieved if an extant message isn't given.""" @@ -85,15 +86,16 @@ class SyncerBaseTests(unittest.TestCase): self.assertIsNone(ret_val) - def test_send_prompt_sends_new_message_if_not_given(self): - """A new message that mentions core devs should be sent if an extant message isn't given.""" + 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_() - asyncio.run(self.syncer._send_prompt()) + mock_channel, mock_message = mock_() + ret_val = asyncio.run(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) def test_send_prompt_adds_reactions(self): """The message should have reactions for confirmation added.""" -- cgit v1.2.3 From d020e5ebaf72448b015351b550ea3c82bde3c61f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 8 Jan 2020 16:42:01 -0800 Subject: Sync tests: create a separate test case for _send_prompt tests --- tests/bot/cogs/sync/test_base.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 4c3eae1b3..af15b544b 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -20,6 +20,18 @@ class TestSyncer(Syncer): 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.TestCase): + """Tests for sending the sync confirmation prompt.""" + def setUp(self): self.bot = helpers.MockBot() self.syncer = TestSyncer(self.bot) @@ -49,11 +61,6 @@ class SyncerBaseTests(unittest.TestCase): return mock_channel, mock_message - 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) - 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() -- cgit v1.2.3 From 7af3d589f51cfabe30d47415baad4420983f53ce Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 9 Jan 2020 11:18:36 -0800 Subject: Sync: make the reaction check an instance method instead of nested The function will be easier to test if it's separate rather than nested. --- bot/cogs/sync/syncers.py | 41 ++++++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 4286609da..e7465d31d 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -2,8 +2,9 @@ import abc import logging import typing as t from collections import namedtuple +from functools import partial -from discord import Guild, HTTPException, Member, Message +from discord import Guild, HTTPException, Member, Message, Reaction, User from discord.ext.commands import Context from bot import constants @@ -80,24 +81,38 @@ class Syncer(abc.ABC): 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_developer == 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. - If `author` is a bot user, then anyone with the core developers role may react to confirm. + Uses the `_reaction_check` function to determine if a reaction is valid. + If there is no reaction within `CONFIRM_TIMEOUT` seconds, return False. To acknowledge the reaction (or lack thereof), `message` will be edited. """ - def check(_reaction, user): # noqa: TYP - # For automatic syncs, check for the core dev role instead of an exact author - has_role = any(constants.Roles.core_developer == 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 - ) - # 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 "" @@ -107,7 +122,7 @@ class Syncer(abc.ABC): log.trace(f"Waiting for a reaction to the {self.name} syncer confirmation prompt.") reaction, _ = await self.bot.wait_for( 'reaction_add', - check=check, + check=partial(self._reaction_check, author, message), timeout=self.CONFIRM_TIMEOUT ) except TimeoutError: -- cgit v1.2.3 From b43b0bc611a0ba7d7ee62bc94a11ac661772f3ca Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 10 Jan 2020 11:46:18 -0800 Subject: Sync tests: create a test suite for confirmation tests --- tests/bot/cogs/sync/test_base.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index af15b544b..ca344c865 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -4,8 +4,8 @@ from unittest import mock import discord -from bot.cogs.sync.syncers import Syncer from bot import constants +from bot.cogs.sync.syncers import Syncer from tests import helpers @@ -122,3 +122,11 @@ class SyncerSendPromptTests(unittest.TestCase): calls = [mock.call(emoji) for emoji in self.syncer._REACTION_EMOJIS] mock_message.add_reaction.assert_has_calls(calls) + + +class SyncerConfirmationTests(unittest.TestCase): + """Tests for waiting for a sync confirmation reaction on the prompt.""" + + def setUp(self): + self.bot = helpers.MockBot() + self.syncer = TestSyncer(self.bot) -- cgit v1.2.3 From 9a73feb93a7680211e597f0cc9d09b06ebc84335 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 10 Jan 2020 11:47:41 -0800 Subject: Sync tests: test _reaction_check for valid emoji and authors Should return True if authors are identical or are a bot and a core dev, respectively. * Create a mock core dev role in the setup fixture * Create a fixture to create a mock message and reaction from an emoji --- tests/bot/cogs/sync/test_base.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index ca344c865..f722a83e8 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -130,3 +130,30 @@ class SyncerConfirmationTests(unittest.TestCase): def setUp(self): self.bot = helpers.MockBot() self.syncer = TestSyncer(self.bot) + self.core_dev_role = helpers.MockRole(id=constants.Roles.core_developer) + + @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)), + ( + helpers.MockMember(id=77, bot=True), + helpers.MockMember(id=43, roles=[self.core_dev_role]), + ) + ) + + for emoji in self.syncer._REACTION_EMOJIS: + for author, user in user_subtests: + with self.subTest(author=author, user=user, emoji=emoji): + message, reaction = self.get_message_reaction(emoji) + ret_val = self.syncer._reaction_check(author, message, reaction, user) + + self.assertTrue(ret_val) -- cgit v1.2.3 From 7ac2d59c485cddc37ef3fd7ebe175cf5bef784fd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Jan 2020 09:18:43 -0800 Subject: Sync tests: test _reaction_check for invalid reactions Should return False for invalid reaction events. --- tests/bot/cogs/sync/test_base.py | 43 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index f722a83e8..43d72dda9 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -157,3 +157,46 @@ class SyncerConfirmationTests(unittest.TestCase): 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) -- cgit v1.2.3 From 13b8c7f4143d5dbc25e07f52fe64bc7a1079ab68 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Jan 2020 09:52:37 -0800 Subject: Sync: fix precedence of conditional expression in _reaction_check --- bot/cogs/sync/syncers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index e7465d31d..6c95b58ad 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -100,7 +100,7 @@ class Syncer(abc.ABC): return ( reaction.message.id == message.id and not user.bot - and has_role if author.bot else user == author + and (has_role if author.bot else user == author) and str(reaction.emoji) in self._REACTION_EMOJIS ) -- cgit v1.2.3 From ad402f5bc8f4db6b97f197fdb518a1b3e7f95eb5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Jan 2020 09:55:45 -0800 Subject: Sync tests: add messages to _reaction_check subtests The message will be displayed by the test runner when a subtest fails. --- tests/bot/cogs/sync/test_base.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 43d72dda9..2d682faad 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -143,16 +143,21 @@ class SyncerConfirmationTests(unittest.TestCase): 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)), + ( + 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 in user_subtests: - with self.subTest(author=author, user=user, emoji=emoji): + 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) -- cgit v1.2.3 From 745c9d15114f90d01f8c21e30c2c40335c199a9e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 14 Jan 2020 10:18:18 -0800 Subject: Tests: add a return value for MockReaction.__str__ --- tests/helpers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/helpers.py b/tests/helpers.py index 71b80a223..b18a27ebe 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -521,6 +521,7 @@ class MockReaction(CustomMockMixin, unittest.mock.MagicMock): self.emoji = kwargs.get('emoji', MockEmoji()) self.message = kwargs.get('message', MockMessage()) self.users = AsyncIteratorMock(kwargs.get('users', [])) + self.__str__.return_value = str(self.emoji) webhook_instance = discord.Webhook(data=unittest.mock.MagicMock(), adapter=unittest.mock.MagicMock()) -- cgit v1.2.3 From 792e7d4bc71ffd7aa6087097b8276a6833c28b90 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 14 Jan 2020 10:27:02 -0800 Subject: Sync tests: test _wait_for_confirmation The message should always be edited and only return True if the emoji is a check mark. --- tests/bot/cogs/sync/test_base.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 2d682faad..d9f9c6d98 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -205,3 +205,41 @@ class SyncerConfirmationTests(unittest.TestCase): with self.subTest(**kwargs, msg=msg): ret_val = self.syncer._reaction_check(*args) self.assertFalse(ret_val) + + 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, 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 = asyncio.run(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) -- cgit v1.2.3 From 555d1f47d75afbaaae2758fac8460d8d6af65d61 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 15 Jan 2020 10:53:38 -0800 Subject: Sync tests: test sync with an empty diff A confirmation prompt should not be sent if the diff is too small. --- tests/bot/cogs/sync/test_base.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index d9f9c6d98..642be75eb 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -5,7 +5,7 @@ from unittest import mock import discord from bot import constants -from bot.cogs.sync.syncers import Syncer +from bot.cogs.sync.syncers import Syncer, _Diff from tests import helpers @@ -243,3 +243,27 @@ class SyncerConfirmationTests(unittest.TestCase): self.assertNotIn(self.syncer._CORE_DEV_MENTION, kwargs["content"]) self.assertIs(actual_return, ret_val) + + +class SyncerSyncTests(unittest.TestCase): + """Tests for main function orchestrating the sync.""" + + def setUp(self): + self.bot = helpers.MockBot() + self.syncer = TestSyncer(self.bot) + + def test_sync_with_empty_diff(self): + """A confirmation prompt should not be sent if the diff is too small.""" + guild = helpers.MockGuild() + diff = _Diff(set(), set(), set()) + + self.syncer._send_prompt = helpers.AsyncMock() + self.syncer._wait_for_confirmation = helpers.AsyncMock() + self.syncer._get_diff.return_value = diff + + asyncio.run(self.syncer.sync(guild)) + + self.syncer._get_diff.assert_called_once_with(guild) + self.syncer._send_prompt.assert_not_called() + self.syncer._wait_for_confirmation.assert_not_called() + self.syncer._sync.assert_called_once_with(diff) -- cgit v1.2.3 From a7ba405732e28e8c44e7ddedce8136f6319980b0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jan 2020 10:43:51 -0800 Subject: Sync tests: test sync sends a confirmation prompt The prompt should be sent only if the diff is large and should fail if not confirmed. The empty diff test was integrated into this new test. --- tests/bot/cogs/sync/test_base.py | 48 ++++++++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 642be75eb..898b12b07 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -252,18 +252,42 @@ class SyncerSyncTests(unittest.TestCase): self.bot = helpers.MockBot() self.syncer = TestSyncer(self.bot) - def test_sync_with_empty_diff(self): - """A confirmation prompt should not be sent if the diff is too small.""" - guild = helpers.MockGuild() - diff = _Diff(set(), set(), set()) + def test_sync_sends_confirmation_prompt(self): + """The prompt should be sent only if the diff is large and should fail if not confirmed.""" + large_diff = _Diff({1}, {2}, {3}) + subtests = ( + (False, False, True, None, None, _Diff({1}, {2}, set()), "diff too small"), + (True, True, True, helpers.MockMessage(), True, large_diff, "confirmed"), + (True, False, False, None, None, large_diff, "couldn't get channel"), + (True, True, False, helpers.MockMessage(), False, large_diff, "not confirmed"), + ) + + for prompt_called, wait_called, sync_called, prompt_msg, confirmed, diff, msg in subtests: + with self.subTest(msg=msg): + self.syncer._sync.reset_mock() + self.syncer._get_diff.reset_mock() + + self.syncer.MAX_DIFF = 2 + self.syncer._get_diff.return_value = diff + self.syncer._send_prompt = helpers.AsyncMock(return_value=prompt_msg) + self.syncer._wait_for_confirmation = helpers.AsyncMock(return_value=confirmed) + + guild = helpers.MockGuild() + asyncio.run(self.syncer.sync(guild)) + + self.syncer._get_diff.assert_called_once_with(guild) - self.syncer._send_prompt = helpers.AsyncMock() - self.syncer._wait_for_confirmation = helpers.AsyncMock() - self.syncer._get_diff.return_value = diff + if prompt_called: + self.syncer._send_prompt.assert_called_once() + else: + self.syncer._send_prompt.assert_not_called() - asyncio.run(self.syncer.sync(guild)) + if wait_called: + self.syncer._wait_for_confirmation.assert_called_once() + else: + self.syncer._wait_for_confirmation.assert_not_called() - self.syncer._get_diff.assert_called_once_with(guild) - self.syncer._send_prompt.assert_not_called() - self.syncer._wait_for_confirmation.assert_not_called() - self.syncer._sync.assert_called_once_with(diff) + if sync_called: + self.syncer._sync.assert_called_once_with(diff) + else: + self.syncer._sync.assert_not_called() -- cgit v1.2.3 From 81716ef72632844e0cf2f33982bbe71cf4b29d7a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jan 2020 11:11:04 -0800 Subject: Sync: create a separate function to get the confirmation result --- bot/cogs/sync/syncers.py | 41 ++++++++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 6c95b58ad..e6faca661 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -150,6 +150,34 @@ class Syncer(abc.ABC): """Perform the API calls for synchronisation.""" raise NotImplementedError + 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 `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 > self.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`. @@ -168,16 +196,11 @@ class Syncer(abc.ABC): diff = await self._get_diff(guild) totals = {k: len(v) for k, v in diff._asdict().items() if v is not None} + diff_size = sum(totals.values()) - log.trace(f"Determining if confirmation prompt should be sent for {self.name} syncer.") - if sum(totals.values()) > self.MAX_DIFF: - message = await self._send_prompt(message) - if not message: - return # Couldn't get channel. - - confirmed = await self._wait_for_confirmation(author, message) - if not confirmed: - return # Sync aborted. + 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. -- cgit v1.2.3 From 08ad97d24590882dbb6a5575b6a3e7bfdbf145a3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jan 2020 09:18:18 -0800 Subject: Sync tests: adjust sync test to account for _get_confirmation_result --- tests/bot/cogs/sync/test_base.py | 36 +++++++++++++----------------------- 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 898b12b07..f82984157 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -252,42 +252,32 @@ class SyncerSyncTests(unittest.TestCase): self.bot = helpers.MockBot() self.syncer = TestSyncer(self.bot) - def test_sync_sends_confirmation_prompt(self): - """The prompt should be sent only if the diff is large and should fail if not confirmed.""" - large_diff = _Diff({1}, {2}, {3}) + def test_sync_respects_confirmation_result(self): + """The sync should abort if confirmation fails and continue if confirmed.""" + mock_message = helpers.MockMessage() subtests = ( - (False, False, True, None, None, _Diff({1}, {2}, set()), "diff too small"), - (True, True, True, helpers.MockMessage(), True, large_diff, "confirmed"), - (True, False, False, None, None, large_diff, "couldn't get channel"), - (True, True, False, helpers.MockMessage(), False, large_diff, "not confirmed"), + (True, mock_message), + (False, None), ) - for prompt_called, wait_called, sync_called, prompt_msg, confirmed, diff, msg in subtests: - with self.subTest(msg=msg): + for confirmed, message in subtests: + with self.subTest(confirmed=confirmed): self.syncer._sync.reset_mock() self.syncer._get_diff.reset_mock() - self.syncer.MAX_DIFF = 2 + diff = _Diff({1, 2, 3}, {4, 5}, None) self.syncer._get_diff.return_value = diff - self.syncer._send_prompt = helpers.AsyncMock(return_value=prompt_msg) - self.syncer._wait_for_confirmation = helpers.AsyncMock(return_value=confirmed) + self.syncer._get_confirmation_result = helpers.AsyncMock( + return_value=(confirmed, message) + ) guild = helpers.MockGuild() asyncio.run(self.syncer.sync(guild)) self.syncer._get_diff.assert_called_once_with(guild) + self.syncer._get_confirmation_result.assert_called_once() - if prompt_called: - self.syncer._send_prompt.assert_called_once() - else: - self.syncer._send_prompt.assert_not_called() - - if wait_called: - self.syncer._wait_for_confirmation.assert_called_once() - else: - self.syncer._wait_for_confirmation.assert_not_called() - - if sync_called: + if confirmed: self.syncer._sync.assert_called_once_with(diff) else: self.syncer._sync.assert_not_called() -- cgit v1.2.3 From 8fab4db24939d6d7dd9256c0faf13395e7caddb7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jan 2020 09:33:40 -0800 Subject: Sync tests: test diff size calculation --- tests/bot/cogs/sync/test_base.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index f82984157..6d784d0de 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -281,3 +281,25 @@ class SyncerSyncTests(unittest.TestCase): self.syncer._sync.assert_called_once_with(diff) else: self.syncer._sync.assert_not_called() + + 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 = helpers.AsyncMock(return_value=(False, None)) + + guild = helpers.MockGuild() + asyncio.run(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) -- cgit v1.2.3 From 7692d506454d5aa125135eac17ed291cc160ef2b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 20 Jan 2020 09:24:46 -0800 Subject: Sync tests: test sync edits the message if one was sent --- tests/bot/cogs/sync/test_base.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 6d784d0de..ae8e53ffa 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -303,3 +303,18 @@ class SyncerSyncTests(unittest.TestCase): 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) + + def test_sync_message_edited(self): + """The message should be edited if one was sent.""" + for message in (helpers.MockMessage(), None): + with self.subTest(message=message): + self.syncer._get_confirmation_result = helpers.AsyncMock( + return_value=(True, message) + ) + + guild = helpers.MockGuild() + asyncio.run(self.syncer.sync(guild)) + + if message is not None: + message.edit.assert_called_once() + self.assertIn("content", message.edit.call_args[1]) -- cgit v1.2.3 From bf22311fb844c7122f2af9b3a51d9c25382fc452 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 21 Jan 2020 17:00:09 -0800 Subject: Sync tests: test sync passes correct author for confirmation Author should be the bot or the ctx author, if a ctx is given. --- tests/bot/cogs/sync/test_base.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index ae8e53ffa..dfc8320d2 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -249,7 +249,7 @@ class SyncerSyncTests(unittest.TestCase): """Tests for main function orchestrating the sync.""" def setUp(self): - self.bot = helpers.MockBot() + self.bot = helpers.MockBot(user=helpers.MockMember(bot=True)) self.syncer = TestSyncer(self.bot) def test_sync_respects_confirmation_result(self): @@ -318,3 +318,21 @@ class SyncerSyncTests(unittest.TestCase): if message is not None: message.edit.assert_called_once() self.assertIn("content", message.edit.call_args[1]) + + def test_sync_confirmation_author(self): + """Author should be the bot or the ctx author, if a ctx is given.""" + mock_member = helpers.MockMember() + subtests = ( + (None, self.bot.user), + (helpers.MockContext(author=mock_member), mock_member), + ) + + for ctx, author in subtests: + with self.subTest(ctx=ctx, author=author): + self.syncer._get_confirmation_result = helpers.AsyncMock(return_value=(False, None)) + + guild = helpers.MockGuild() + asyncio.run(self.syncer.sync(guild, ctx)) + + self.syncer._get_confirmation_result.assert_called_once() + self.assertEqual(self.syncer._get_confirmation_result.call_args[0][1], author) -- cgit v1.2.3 From eaf44846fd8eaee3f52ca1d8b2f146655298b488 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 22 Jan 2020 18:07:29 -0800 Subject: Sync tests: test sync redirects confirmation message to given context If ctx is given, a new message should be sent and author should be ctx's author. test_sync_confirmation_author was re-worked to include a test for the message being sent and passed. --- tests/bot/cogs/sync/test_base.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index dfc8320d2..a2df3e24e 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -319,20 +319,27 @@ class SyncerSyncTests(unittest.TestCase): message.edit.assert_called_once() self.assertIn("content", message.edit.call_args[1]) - def test_sync_confirmation_author(self): - """Author should be the bot or the ctx author, if a ctx is given.""" + 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), - (helpers.MockContext(author=mock_member), mock_member), + (None, self.bot.user, None), + (helpers.MockContext(author=mock_member), mock_member, helpers.MockMessage()), ) - for ctx, author in subtests: - with self.subTest(ctx=ctx, author=author): + 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 + self.syncer._get_confirmation_result = helpers.AsyncMock(return_value=(False, None)) guild = helpers.MockGuild() asyncio.run(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) -- cgit v1.2.3 From 879ada59bf0a17f5cbf2590a7eb2426825b3635e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 23 Jan 2020 13:35:36 -0800 Subject: Sync tests: test sync edits message even if there's an API error --- tests/bot/cogs/sync/test_base.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index a2df3e24e..314f8a70c 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -5,6 +5,7 @@ from unittest import mock import discord from bot import constants +from bot.api import ResponseCodeError from bot.cogs.sync.syncers import Syncer, _Diff from tests import helpers @@ -305,9 +306,16 @@ class SyncerSyncTests(unittest.TestCase): self.assertEqual(self.syncer._get_confirmation_result.call_args[0][0], size) def test_sync_message_edited(self): - """The message should be edited if one was sent.""" - for message in (helpers.MockMessage(), None): - with self.subTest(message=message): + """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 = helpers.AsyncMock( return_value=(True, message) ) @@ -315,7 +323,7 @@ class SyncerSyncTests(unittest.TestCase): guild = helpers.MockGuild() asyncio.run(self.syncer.sync(guild)) - if message is not None: + if should_edit: message.edit.assert_called_once() self.assertIn("content", message.edit.call_args[1]) -- cgit v1.2.3 From dfd4ca2bf4d4b8717a648d3f291cc3daeeb762d4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 24 Jan 2020 18:32:10 -0800 Subject: Sync tests: test _get_confirmation_result for small diffs Should always return True and the given message if the diff size is too small. --- tests/bot/cogs/sync/test_base.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 314f8a70c..21f14f89a 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -351,3 +351,22 @@ class SyncerSyncTests(unittest.TestCase): 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) + + def test_confirmation_result_small_diff(self): + """Should always return True and the given message if the diff size is too small.""" + self.syncer.MAX_DIFF = 3 + author = helpers.MockMember() + expected_message = helpers.MockMessage() + + for size in (3, 2): + with self.subTest(size=size): + self.syncer._send_prompt = helpers.AsyncMock() + self.syncer._wait_for_confirmation = helpers.AsyncMock() + + coro = self.syncer._get_confirmation_result(size, author, expected_message) + result, actual_message = asyncio.run(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() -- cgit v1.2.3 From 4385422fc0f64cb592a9bb1d5815cc91a0ca09a0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 24 Jan 2020 18:48:40 -0800 Subject: Sync tests: test _get_confirmation_result for large diffs Should return True if confirmed and False if _send_prompt fails or aborted. --- tests/bot/cogs/sync/test_base.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 21f14f89a..ff11d911e 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -370,3 +370,32 @@ class SyncerSyncTests(unittest.TestCase): self.assertEqual(actual_message, expected_message) self.syncer._send_prompt.assert_not_called() self.syncer._wait_for_confirmation.assert_not_called() + + def test_confirmation_result_large_diff(self): + """Should return True if confirmed and False if _send_prompt fails or aborted.""" + self.syncer.MAX_DIFF = 3 + 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: + with self.subTest(msg=msg): + self.syncer._send_prompt = helpers.AsyncMock(return_value=expected_message) + self.syncer._wait_for_confirmation = helpers.AsyncMock(return_value=confirmed) + + coro = self.syncer._get_confirmation_result(4, author) + actual_result, actual_message = asyncio.run(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 2a8c545a4d3d39a9d9659b607872c7f5653051ea Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 25 Jan 2020 18:42:12 -0800 Subject: Sync tests: ignore coverage for abstract methods It's impossible to create an instance of the base class which does not have the abstract methods implemented, so it doesn't really matter what they do. --- bot/cogs/sync/syncers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index e6faca661..23039d1fc 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -36,7 +36,7 @@ class Syncer(abc.ABC): @abc.abstractmethod def name(self) -> str: """The name of the syncer; used in output messages and logging.""" - raise NotImplementedError + raise NotImplementedError # pragma: no cover async def _send_prompt(self, message: t.Optional[Message] = None) -> t.Optional[Message]: """ @@ -143,12 +143,12 @@ class Syncer(abc.ABC): @abc.abstractmethod async def _get_diff(self, guild: Guild) -> _Diff: """Return the difference between the cache of `guild` and the database.""" - raise NotImplementedError + raise NotImplementedError # pragma: no cover @abc.abstractmethod async def _sync(self, diff: _Diff) -> None: """Perform the API calls for synchronisation.""" - raise NotImplementedError + raise NotImplementedError # pragma: no cover async def _get_confirmation_result( self, -- cgit v1.2.3 From 69f59078394193f615753b0a20d74982e58d5c0f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 26 Jan 2020 18:27:06 -0800 Subject: Sync tests: test the extension setup The Sync cog should be added. --- tests/bot/cogs/sync/test_cog.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 tests/bot/cogs/sync/test_cog.py diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py new file mode 100644 index 000000000..fb0f044b0 --- /dev/null +++ b/tests/bot/cogs/sync/test_cog.py @@ -0,0 +1,15 @@ +import unittest + +from bot.cogs import sync +from tests import helpers + + +class SyncExtensionTests(unittest.TestCase): + """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() -- cgit v1.2.3 From 32048b12d98d3b04a336ae53e12b81681a51e72a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 27 Jan 2020 22:00:11 -0800 Subject: Sync tests: test Sync cog __init__ Should instantiate syncers and run a sync for the guild. --- tests/bot/cogs/sync/test_cog.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index fb0f044b0..efffaf53b 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -1,4 +1,5 @@ import unittest +from unittest import mock from bot.cogs import sync from tests import helpers @@ -13,3 +14,23 @@ class SyncExtensionTests(unittest.TestCase): bot = helpers.MockBot() sync.setup(bot) bot.add_cog.assert_called_once() + + +class SyncCogTests(unittest.TestCase): + """Tests for the Sync cog.""" + + def setUp(self): + self.bot = helpers.MockBot() + + @mock.patch("bot.cogs.sync.syncers.RoleSyncer", autospec=True) + @mock.patch("bot.cogs.sync.syncers.UserSyncer", autospec=True) + def test_sync_cog_init(self, mock_role, mock_sync): + """Should instantiate syncers and run a sync for the guild.""" + mock_sync_guild_coro = mock.MagicMock() + sync.Sync.sync_guild = mock.MagicMock(return_value=mock_sync_guild_coro) + + sync.Sync(self.bot) + + mock_role.assert_called_once_with(self.bot) + mock_sync.assert_called_once_with(self.bot) + self.bot.loop.create_task.assert_called_once_with(mock_sync_guild_coro) -- cgit v1.2.3 From f9e72150b5e2f4c2ae4b3968ef2d2da29fd5adbd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 28 Jan 2020 18:31:06 -0800 Subject: Sync tests: instantiate a Sync cog in setUp * Move patches to setUp --- tests/bot/cogs/sync/test_cog.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index efffaf53b..74afa2f9d 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -22,15 +22,29 @@ class SyncCogTests(unittest.TestCase): def setUp(self): self.bot = helpers.MockBot() - @mock.patch("bot.cogs.sync.syncers.RoleSyncer", autospec=True) - @mock.patch("bot.cogs.sync.syncers.UserSyncer", autospec=True) - def test_sync_cog_init(self, mock_role, mock_sync): + self.role_syncer_patcher = mock.patch("bot.cogs.sync.syncers.RoleSyncer", autospec=True) + self.user_syncer_patcher = mock.patch("bot.cogs.sync.syncers.UserSyncer", autospec=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() + + def test_sync_cog_init(self): """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.reset_mock() + mock_sync_guild_coro = mock.MagicMock() sync.Sync.sync_guild = mock.MagicMock(return_value=mock_sync_guild_coro) sync.Sync(self.bot) - mock_role.assert_called_once_with(self.bot) - mock_sync.assert_called_once_with(self.bot) + self.RoleSyncer.assert_called_once_with(self.bot) + self.UserSyncer.assert_called_once_with(self.bot) self.bot.loop.create_task.assert_called_once_with(mock_sync_guild_coro) -- cgit v1.2.3 From f1502c6cc6c65be5b2b29066c8a2d774e73935d9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 29 Jan 2020 17:00:55 -0800 Subject: Sync tests: use mock.patch for sync_guild This prevents persistence of changes to the cog instance; sync_guild would otherwise remain as a mock object for any subsequent tests. --- tests/bot/cogs/sync/test_cog.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 74afa2f9d..118782db3 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -33,7 +33,8 @@ class SyncCogTests(unittest.TestCase): self.role_syncer_patcher.stop() self.user_syncer_patcher.stop() - def test_sync_cog_init(self): + @mock.patch.object(sync.Sync, "sync_guild") + 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() @@ -41,10 +42,11 @@ class SyncCogTests(unittest.TestCase): self.bot.loop.create_task.reset_mock() mock_sync_guild_coro = mock.MagicMock() - sync.Sync.sync_guild = mock.MagicMock(return_value=mock_sync_guild_coro) + 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) -- cgit v1.2.3 From bd5980728bd7bfd5bba53369934698c43f12fa05 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 29 Jan 2020 17:09:43 -0800 Subject: Sync tests: fix Syncer mocks not having async methods While on 3.7, the CustomMockMixin needs to be leveraged so that coroutine members are replace with AsyncMocks instead. --- tests/bot/cogs/sync/test_cog.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 118782db3..ec66c795d 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -2,9 +2,21 @@ import unittest from unittest import mock from bot.cogs import sync +from bot.cogs.sync.syncers import Syncer from tests import helpers +class MockSyncer(helpers.CustomMockMixin, mock.MagicMock): + """ + A MagicMock subclass to mock Syncer objects. + + Instances of this class will follow the specifications of `bot.cogs.sync.syncers.Syncer` + instances. For more information, see the `MockGuild` docstring. + """ + def __init__(self, **kwargs) -> None: + super().__init__(spec_set=Syncer, **kwargs) + + class SyncExtensionTests(unittest.TestCase): """Tests for the sync extension.""" @@ -22,8 +34,17 @@ class SyncCogTests(unittest.TestCase): def setUp(self): self.bot = helpers.MockBot() - self.role_syncer_patcher = mock.patch("bot.cogs.sync.syncers.RoleSyncer", autospec=True) - self.user_syncer_patcher = mock.patch("bot.cogs.sync.syncers.UserSyncer", autospec=True) + # These patch the type. When the type is called, a MockSyncer instanced is returned. + # MockSyncer is needed so that our custom AsyncMock is used. + # TODO: Use autospec instead in 3.8, which will automatically use AsyncMock when needed. + self.role_syncer_patcher = mock.patch( + "bot.cogs.sync.syncers.RoleSyncer", + new=mock.MagicMock(return_value=MockSyncer()) + ) + self.user_syncer_patcher = mock.patch( + "bot.cogs.sync.syncers.UserSyncer", + new=mock.MagicMock(return_value=MockSyncer()) + ) self.RoleSyncer = self.role_syncer_patcher.start() self.UserSyncer = self.user_syncer_patcher.start() -- cgit v1.2.3 From 607e4480badd58d5de36d5be3306498afcb4348c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 29 Jan 2020 18:47:58 -0800 Subject: Sync tests: test sync_guild Roles and users should be synced only if a guild is successfully retrieved. --- tests/bot/cogs/sync/test_cog.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index ec66c795d..09ce0ae16 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -1,6 +1,8 @@ +import asyncio import unittest from unittest import mock +from bot import constants from bot.cogs import sync from bot.cogs.sync.syncers import Syncer from tests import helpers @@ -71,3 +73,25 @@ class SyncCogTests(unittest.TestCase): 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) + + 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) + + asyncio.run(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) -- cgit v1.2.3 From a0253c2349bead625633737964ba4203d75db7aa Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 31 Jan 2020 11:21:19 -0800 Subject: Sync tests: test patch_user A PATCH request should be sent. The error should only be raised if it is not a 404. * Add a fixture to create ResponseCodeErrors with a specific status --- tests/bot/cogs/sync/test_cog.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 09ce0ae16..0eb8954f1 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -3,6 +3,7 @@ import unittest from unittest import mock from bot import constants +from bot.api import ResponseCodeError from bot.cogs import sync from bot.cogs.sync.syncers import Syncer from tests import helpers @@ -56,6 +57,14 @@ class SyncCogTests(unittest.TestCase): 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) + @mock.patch.object(sync.Sync, "sync_guild") def test_sync_cog_init(self, sync_guild): """Should instantiate syncers and run a sync for the guild.""" @@ -95,3 +104,20 @@ class SyncCogTests(unittest.TestCase): else: self.cog.role_syncer.sync.assert_called_once_with(guild) self.cog.user_syncer.sync.assert_called_once_with(guild) + + 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): + self.bot.api_client.patch.reset_mock(side_effect=True) + self.bot.api_client.patch.side_effect = side_effect + + asyncio.run(self.cog.patch_user(5, {})) + self.bot.api_client.patch.assert_called_once() + + 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.""" + self.bot.api_client.patch.side_effect = self.response_error(500) + with self.assertRaises(ResponseCodeError): + asyncio.run(self.cog.patch_user(5, {})) + self.bot.api_client.patch.assert_called_once() -- cgit v1.2.3 From 93b3ec43526096bdf3f4c8a9ee2c9de29d25a562 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 31 Jan 2020 12:52:40 -0800 Subject: Sync tests: add helper function for testing patch_user Reduces redundancy in the tests by taking care of the mocks, calling of the function, and the assertion. --- tests/bot/cogs/sync/test_cog.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 0eb8954f1..bdb7aeb63 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -105,19 +105,26 @@ class SyncCogTests(unittest.TestCase): self.cog.role_syncer.sync.assert_called_once_with(guild) self.cog.user_syncer.sync.assert_called_once_with(guild) + 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} + asyncio.run(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, + ) + 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): - self.bot.api_client.patch.reset_mock(side_effect=True) - self.bot.api_client.patch.side_effect = side_effect - - asyncio.run(self.cog.patch_user(5, {})) - self.bot.api_client.patch.assert_called_once() + self.patch_user_helper(side_effect) 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.""" - self.bot.api_client.patch.side_effect = self.response_error(500) with self.assertRaises(ResponseCodeError): - asyncio.run(self.cog.patch_user(5, {})) - self.bot.api_client.patch.assert_called_once() + self.patch_user_helper(self.response_error(500)) -- cgit v1.2.3 From 097a5231067320b73277852202444c404bb0adbb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 31 Jan 2020 13:39:19 -0800 Subject: Sync tests: create a base TestCase class for Sync cog tests --- tests/bot/cogs/sync/test_cog.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index bdb7aeb63..c6009b2e5 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -31,8 +31,8 @@ class SyncExtensionTests(unittest.TestCase): bot.add_cog.assert_called_once() -class SyncCogTests(unittest.TestCase): - """Tests for the Sync cog.""" +class SyncCogTestCase(unittest.TestCase): + """Base class for Sync cog tests. Sets up patches for syncers.""" def setUp(self): self.bot = helpers.MockBot() @@ -65,6 +65,10 @@ class SyncCogTests(unittest.TestCase): return ResponseCodeError(response) + +class SyncCogTests(SyncCogTestCase): + """Tests for the Sync cog.""" + @mock.patch.object(sync.Sync, "sync_guild") def test_sync_cog_init(self, sync_guild): """Should instantiate syncers and run a sync for the guild.""" -- cgit v1.2.3 From 3c0937de8641092100acc6424f4455c49d2e7855 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 31 Jan 2020 13:54:20 -0800 Subject: Sync tests: create a test case for listener tests --- tests/bot/cogs/sync/test_cog.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index c6009b2e5..d71366791 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -132,3 +132,10 @@ class SyncCogTests(SyncCogTestCase): """A PATCH request should be sent and the error raised if it's not a 404.""" with self.assertRaises(ResponseCodeError): 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 = helpers.AsyncMock(spec_set=self.cog.patch_user) -- cgit v1.2.3 From 948661e3738ae2bd2636631bf2a91c1589aa0bde Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 31 Jan 2020 14:04:17 -0800 Subject: Sync tests: test Sync cog's on_guild_role_create listener A POST request should be sent with the new role's data. * Add a fixture to create a MockRole --- tests/bot/cogs/sync/test_cog.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index d71366791..a4969551d 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -1,7 +1,10 @@ import asyncio +import typing as t import unittest from unittest import mock +import discord + from bot import constants from bot.api import ResponseCodeError from bot.cogs import sync @@ -139,3 +142,29 @@ class SyncCogListenerTests(SyncCogTestCase): def setUp(self): super().setUp() self.cog.patch_user = helpers.AsyncMock(spec_set=self.cog.patch_user) + + @staticmethod + def mock_role() -> t.Tuple[helpers.MockRole, t.Dict[str, t.Any]]: + """Fixture to return a MockRole and corresponding JSON dict.""" + colour = 49 + permissions = 8 + role_data = { + "colour": colour, + "id": 777, + "name": "rolename", + "permissions": permissions, + "position": 23, + } + + role = helpers.MockRole(**role_data) + role.colour = discord.Colour(colour) + role.permissions = discord.Permissions(permissions) + + return role, role_data + + def test_sync_cog_on_guild_role_create(self): + """A POST request should be sent with the new role's data.""" + role, role_data = self.mock_role() + asyncio.run(self.cog.on_guild_role_create(role)) + + self.bot.api_client.post.assert_called_once_with("bot/roles", json=role_data) -- cgit v1.2.3 From d249d4517cbd903a550047bd91e9c83bf828b9d0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 31 Jan 2020 14:10:52 -0800 Subject: Sync tests: test Sync cog's on_guild_role_delete listener A DELETE request should be sent. --- tests/bot/cogs/sync/test_cog.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index a4969551d..e183b429f 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -168,3 +168,10 @@ class SyncCogListenerTests(SyncCogTestCase): asyncio.run(self.cog.on_guild_role_create(role)) self.bot.api_client.post.assert_called_once_with("bot/roles", json=role_data) + + def test_sync_cog_on_guild_role_delete(self): + """A DELETE request should be sent.""" + role = helpers.MockRole(id=99) + asyncio.run(self.cog.on_guild_role_delete(role)) + + self.bot.api_client.delete.assert_called_once_with("bot/roles/99") -- cgit v1.2.3 From 535095ff647277922b7d1930da8d038f15af74fd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 1 Feb 2020 15:32:16 -0800 Subject: Tests: use objects for colour and permissions of MockRole Instances of discord.Colour and discord.Permissions will be created by default or when ints are given as values for those attributes. --- tests/helpers.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/helpers.py b/tests/helpers.py index b18a27ebe..a40673bb9 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -270,9 +270,21 @@ class MockRole(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): information, see the `MockGuild` docstring. """ def __init__(self, **kwargs) -> None: - default_kwargs = {'id': next(self.discord_id), 'name': 'role', 'position': 1} + default_kwargs = { + 'id': next(self.discord_id), + 'name': 'role', + 'position': 1, + 'colour': discord.Colour(0xdeadbf), + 'permissions': discord.Permissions(), + } super().__init__(spec_set=role_instance, **collections.ChainMap(kwargs, default_kwargs)) + if isinstance(self.colour, int): + self.colour = discord.Colour(self.colour) + + if isinstance(self.permissions, int): + self.permissions = discord.Permissions(self.permissions) + if 'mention' not in kwargs: self.mention = f'&{self.name}' -- cgit v1.2.3 From 4e81281ecc87a6d2af320b3c000aea286a50f2a7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 1 Feb 2020 20:58:15 -0800 Subject: Sync tests: remove mock_role fixture It is obsolete because MockRole now takes care of creating the Colour and Permissions objects. --- tests/bot/cogs/sync/test_cog.py | 23 ++++------------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index e183b429f..604daa437 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -1,10 +1,7 @@ import asyncio -import typing as t import unittest from unittest import mock -import discord - from bot import constants from bot.api import ResponseCodeError from bot.cogs import sync @@ -143,28 +140,16 @@ class SyncCogListenerTests(SyncCogTestCase): super().setUp() self.cog.patch_user = helpers.AsyncMock(spec_set=self.cog.patch_user) - @staticmethod - def mock_role() -> t.Tuple[helpers.MockRole, t.Dict[str, t.Any]]: - """Fixture to return a MockRole and corresponding JSON dict.""" - colour = 49 - permissions = 8 + def test_sync_cog_on_guild_role_create(self): + """A POST request should be sent with the new role's data.""" role_data = { - "colour": colour, + "colour": 49, "id": 777, "name": "rolename", - "permissions": permissions, + "permissions": 8, "position": 23, } - role = helpers.MockRole(**role_data) - role.colour = discord.Colour(colour) - role.permissions = discord.Permissions(permissions) - - return role, role_data - - def test_sync_cog_on_guild_role_create(self): - """A POST request should be sent with the new role's data.""" - role, role_data = self.mock_role() asyncio.run(self.cog.on_guild_role_create(role)) self.bot.api_client.post.assert_called_once_with("bot/roles", json=role_data) -- cgit v1.2.3 From ad53b51b860858cb9434435de3d205165b2d78f8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 1 Feb 2020 21:35:33 -0800 Subject: Sync tests: test Sync cog's on_guild_role_update A PUT request should be sent if the colour, name, permissions, or position changes. --- tests/bot/cogs/sync/test_cog.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 604daa437..9a3232b3a 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -160,3 +160,38 @@ class SyncCogListenerTests(SyncCogTestCase): asyncio.run(self.cog.on_guild_role_delete(role)) self.bot.api_client.delete.assert_called_once_with("bot/roles/99") + + def test_sync_cog_on_guild_role_update(self): + """A PUT request should be sent if the colour, name, permissions, or position changes.""" + 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) + after_role = helpers.MockRole(**after_role_data) + + asyncio.run(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() -- cgit v1.2.3 From 524026576d89cf84d0e44b3cb36ee8810e924396 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 4 Feb 2020 21:07:52 -0800 Subject: Sync tests: test Sync cog's on_member_remove A PUT request should be sent to set in_guild as False and update other fields. --- tests/bot/cogs/sync/test_cog.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 9a3232b3a..4ee66a518 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -195,3 +195,20 @@ class SyncCogListenerTests(SyncCogTestCase): ) else: self.bot.api_client.put.assert_not_called() + + def test_sync_cog_on_member_remove(self): + """A PUT request should be sent to set in_guild as False and update other fields.""" + roles = [helpers.MockRole(id=i) for i in (57, 22, 43)] # purposefully unsorted + member = helpers.MockMember(roles=roles) + + asyncio.run(self.cog.on_member_remove(member)) + + json_data = { + "avatar_hash": member.avatar, + "discriminator": int(member.discriminator), + "id": member.id, + "in_guild": False, + "name": member.name, + "roles": sorted(role.id for role in member.roles) + } + self.bot.api_client.put.assert_called_once_with("bot/users/88", json=json_data) -- cgit v1.2.3 From 7748de87d507d2732c58a77ae6300b8c925fa8c9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 5 Feb 2020 11:34:01 -0800 Subject: Sync tests: test Sync cog's on_member_update for roles Members should be patched if their roles have changed. --- tests/bot/cogs/sync/test_cog.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 4ee66a518..f04d53caa 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -212,3 +212,14 @@ class SyncCogListenerTests(SyncCogTestCase): "roles": sorted(role.id for role in member.roles) } self.bot.api_client.put.assert_called_once_with("bot/users/88", json=json_data) + + def test_sync_cog_on_member_update_roles(self): + """Members should be patched if their roles have changed.""" + before_roles = [helpers.MockRole(id=12), helpers.MockRole(id=30)] + before_member = helpers.MockMember(roles=before_roles) + after_member = helpers.MockMember(roles=before_roles[1:]) + + asyncio.run(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, updated_information=data) -- cgit v1.2.3 From 562a33184b52525bc8f9cfda8aaeb8245087e135 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 5 Feb 2020 11:37:20 -0800 Subject: Sync tests: test Sync cog's on_member_update for other attributes Members should not be patched if other attributes have changed. --- tests/bot/cogs/sync/test_cog.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index f04d53caa..36945b82e 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -2,6 +2,8 @@ import asyncio import unittest from unittest import mock +import discord + from bot import constants from bot.api import ResponseCodeError from bot.cogs import sync @@ -223,3 +225,22 @@ class SyncCogListenerTests(SyncCogTestCase): data = {"roles": sorted(role.id for role in after_member.roles)} self.cog.patch_user.assert_called_once_with(after_member.id, updated_information=data) + + def test_sync_cog_on_member_update_other(self): + """Members should not be patched if other attributes have changed.""" + 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}) + after_member = helpers.MockMember(**{attribute: new_value}) + + asyncio.run(self.cog.on_member_update(before_member, after_member)) + + self.cog.patch_user.assert_not_called() -- cgit v1.2.3 From df1e4f10b4ffc6a514528d03d10d3854385986ac Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 5 Feb 2020 12:08:23 -0800 Subject: Sync tests: fix ID in endpoint for test_sync_cog_on_member_remove --- tests/bot/cogs/sync/test_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 36945b82e..75165a5b2 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -213,7 +213,7 @@ class SyncCogListenerTests(SyncCogTestCase): "name": member.name, "roles": sorted(role.id for role in member.roles) } - self.bot.api_client.put.assert_called_once_with("bot/users/88", json=json_data) + self.bot.api_client.put.assert_called_once_with(f"bot/users/{member.id}", json=json_data) def test_sync_cog_on_member_update_roles(self): """Members should be patched if their roles have changed.""" -- cgit v1.2.3 From b3d19d72596052629f56823dfd6c63b42dda6253 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 5 Feb 2020 13:14:33 -0800 Subject: Sync tests: test Sync cog's on_user_update A user should be patched only if the name, discriminator, or avatar changes. --- tests/bot/cogs/sync/test_cog.py | 43 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 75165a5b2..88c5e00b9 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -231,7 +231,7 @@ class SyncCogListenerTests(SyncCogTestCase): subtests = ( ("activities", discord.Game("Pong"), discord.Game("Frogger")), ("nick", "old nick", "new nick"), - ("status", discord.Status.online, discord.Status.offline) + ("status", discord.Status.online, discord.Status.offline), ) for attribute, old_value, new_value in subtests: @@ -244,3 +244,44 @@ class SyncCogListenerTests(SyncCogTestCase): asyncio.run(self.cog.on_member_update(before_member, after_member)) self.cog.patch_user.assert_not_called() + + def test_sync_cog_on_user_update(self): + """A user should be patched only if the name, discriminator, or avatar changes.""" + before_data = { + "name": "old name", + "discriminator": "1234", + "avatar": "old avatar", + "bot": False, + } + + subtests = ( + (True, "name", "name", "new name", "new name"), + (True, "discriminator", "discriminator", "8765", 8765), + (True, "avatar", "avatar_hash", "9j2e9", "9j2e9"), + (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) + + asyncio.run(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[0][0], after_user.id) + self.assertIn("updated_information", call_args[1]) + + updated_information = call_args[1]["updated_information"] + self.assertIn(api_field, updated_information) + self.assertEqual(updated_information[api_field], api_value) + else: + self.cog.patch_user.assert_not_called() -- cgit v1.2.3 From 5a685dfa2a99ee61a898940812b289cb9f448fdc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 6 Feb 2020 13:01:25 -0800 Subject: Sync tests: test sync roles command sync() should be called on the RoleSyncer. --- tests/bot/cogs/sync/test_cog.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 88c5e00b9..4de058965 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -285,3 +285,12 @@ class SyncCogListenerTests(SyncCogTestCase): self.assertEqual(updated_information[api_field], api_value) else: self.cog.patch_user.assert_not_called() + + +class SyncCogCommandTests(SyncCogTestCase): + def test_sync_roles_command(self): + """sync() should be called on the RoleSyncer.""" + ctx = helpers.MockContext() + asyncio.run(self.cog.sync_roles_command.callback(self.cog, ctx)) + + self.cog.role_syncer.sync.assert_called_once_with(ctx.guild, ctx) -- cgit v1.2.3 From 0e7211e80c76973e781db3bbea82a54e6a9ebb1c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 6 Feb 2020 13:11:37 -0800 Subject: Sync tests: test sync users command sync() should be called on the UserSyncer. --- tests/bot/cogs/sync/test_cog.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 4de058965..f21d1574b 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -294,3 +294,10 @@ class SyncCogCommandTests(SyncCogTestCase): asyncio.run(self.cog.sync_roles_command.callback(self.cog, ctx)) self.cog.role_syncer.sync.assert_called_once_with(ctx.guild, ctx) + + def test_sync_users_command(self): + """sync() should be called on the UserSyncer.""" + ctx = helpers.MockContext() + asyncio.run(self.cog.sync_users_command.callback(self.cog, ctx)) + + self.cog.user_syncer.sync.assert_called_once_with(ctx.guild, ctx) -- cgit v1.2.3 From 548f258314513cc41a0e4339b6eaa06be75a8f5d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 7 Feb 2020 11:30:52 -0800 Subject: Sync: only update in_guild field when a member leaves The member and user update listeners should already be detecting and updating other fields so by the time a user leaves, the rest of the fields should be up-to-date. * Dedent condition which was indented too far --- bot/cogs/sync/cog.py | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 66ffbabf9..ee3cccbfa 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -66,10 +66,10 @@ class Sync(Cog): 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 ( - before.name != after.name - or before.colour != after.colour - or before.permissions != after.permissions - or before.position != after.position + before.name != after.name + or before.colour != after.colour + or before.permissions != after.permissions + or before.position != after.position ): await self.bot.api_client.put( f'bot/roles/{after.id}', @@ -120,18 +120,8 @@ class Sync(Cog): @Cog.listener() async def on_member_remove(self, member: Member) -> None: - """Updates the user information when a member leaves the guild.""" - await self.bot.api_client.put( - f'bot/users/{member.id}', - json={ - 'avatar_hash': member.avatar, - 'discriminator': int(member.discriminator), - 'id': member.id, - 'in_guild': False, - 'name': member.name, - 'roles': sorted(role.id for role in member.roles) - } - ) + """Set the in_guild field to False when a member leaves the guild.""" + await self.patch_user(member.id, updated_information={"in_guild": False}) @Cog.listener() async def on_member_update(self, before: Member, after: Member) -> None: -- cgit v1.2.3 From 7b9e71fbb1364a416e5239b45434874fed9eb857 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 10 Feb 2020 16:31:07 -0800 Subject: Tests: create TestCase subclass with a permissions check assertion The subclass will contain assertions that are useful for testing Discord commands. The currently included assertion tests that a command will raise a MissingPermissions exception if the author lacks permissions. --- tests/base.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/base.py b/tests/base.py index 029a249ed..88693f382 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1,6 +1,12 @@ import logging import unittest from contextlib import contextmanager +from typing import Dict + +import discord +from discord.ext import commands + +from tests import helpers class _CaptureLogHandler(logging.Handler): @@ -65,3 +71,31 @@ class LoggingTestCase(unittest.TestCase): standard_message = self._truncateMessage(base_message, record_message) msg = self._formatMessage(msg, standard_message) self.fail(msg) + + +class CommandTestCase(unittest.TestCase): + """TestCase with additional assertions that are useful for testing Discord commands.""" + + @helpers.async_test + async def assertHasPermissionsCheck( + self, + cmd: commands.Command, + permissions: Dict[str, bool], + ) -> None: + """ + Test that `cmd` raises a `MissingPermissions` exception if author lacks `permissions`. + + Every permission in `permissions` is expected to be reported as missing. In other words, do + not include permissions which should not raise an exception along with those which should. + """ + # Invert permission values because it's more intuitive to pass to this assertion the same + # permissions as those given to the check decorator. + permissions = {k: not v for k, v in permissions.items()} + + ctx = helpers.MockContext() + ctx.channel.permissions_for.return_value = discord.Permissions(**permissions) + + with self.assertRaises(commands.MissingPermissions) as cm: + await cmd.can_run(ctx) + + self.assertCountEqual(permissions.keys(), cm.exception.missing_perms) -- cgit v1.2.3 From 2d0f25c2472b94e2b40fc12cc49fd2ad4272c9ee Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 10 Feb 2020 16:49:27 -0800 Subject: Sync tests: test sync commands require the admin permission The sync commands should only run if the author has the administrator permission. * Add missing spaces after class docstrings * Add missing docstring to SyncCogCommandTests --- tests/bot/cogs/sync/test_cog.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index f21d1574b..b1f586a5b 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -9,6 +9,7 @@ from bot.api import ResponseCodeError from bot.cogs import sync from bot.cogs.sync.syncers import Syncer from tests import helpers +from tests.base import CommandTestCase class MockSyncer(helpers.CustomMockMixin, mock.MagicMock): @@ -18,6 +19,7 @@ class MockSyncer(helpers.CustomMockMixin, mock.MagicMock): Instances of this class will follow the specifications of `bot.cogs.sync.syncers.Syncer` instances. For more information, see the `MockGuild` docstring. """ + def __init__(self, **kwargs) -> None: super().__init__(spec_set=Syncer, **kwargs) @@ -138,6 +140,7 @@ class SyncCogTests(SyncCogTestCase): class SyncCogListenerTests(SyncCogTestCase): """Tests for the listeners of the Sync cog.""" + def setUp(self): super().setUp() self.cog.patch_user = helpers.AsyncMock(spec_set=self.cog.patch_user) @@ -287,7 +290,9 @@ class SyncCogListenerTests(SyncCogTestCase): self.cog.patch_user.assert_not_called() -class SyncCogCommandTests(SyncCogTestCase): +class SyncCogCommandTests(SyncCogTestCase, CommandTestCase): + """Tests for the commands in the Sync cog.""" + def test_sync_roles_command(self): """sync() should be called on the RoleSyncer.""" ctx = helpers.MockContext() @@ -301,3 +306,15 @@ class SyncCogCommandTests(SyncCogTestCase): asyncio.run(self.cog.sync_users_command.callback(self.cog, ctx)) self.cog.user_syncer.sync.assert_called_once_with(ctx.guild, ctx) + + 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): + self.assertHasPermissionsCheck(cmd, {"administrator": True}) -- cgit v1.2.3 From e8b1fa52daf5950ad253e52c3b386a9d4967e739 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 10 Feb 2020 17:02:02 -0800 Subject: Sync tests: assert that listeners are actually added as listeners --- tests/bot/cogs/sync/test_cog.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index b1f586a5b..f7e86f063 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -147,6 +147,8 @@ class SyncCogListenerTests(SyncCogTestCase): 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, @@ -161,6 +163,8 @@ class SyncCogListenerTests(SyncCogTestCase): 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) asyncio.run(self.cog.on_guild_role_delete(role)) @@ -168,6 +172,8 @@ class SyncCogListenerTests(SyncCogTestCase): 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, @@ -203,6 +209,8 @@ class SyncCogListenerTests(SyncCogTestCase): def test_sync_cog_on_member_remove(self): """A PUT request should be sent to set in_guild as False and update other fields.""" + self.assertTrue(self.cog.on_member_remove.__cog_listener__) + roles = [helpers.MockRole(id=i) for i in (57, 22, 43)] # purposefully unsorted member = helpers.MockMember(roles=roles) @@ -220,6 +228,8 @@ class SyncCogListenerTests(SyncCogTestCase): 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__) + before_roles = [helpers.MockRole(id=12), helpers.MockRole(id=30)] before_member = helpers.MockMember(roles=before_roles) after_member = helpers.MockMember(roles=before_roles[1:]) @@ -231,6 +241,8 @@ class SyncCogListenerTests(SyncCogTestCase): 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"), @@ -250,6 +262,8 @@ class SyncCogListenerTests(SyncCogTestCase): 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", -- cgit v1.2.3 From 5c385da1a41b2a6463b38b1973e13fd4590d61cb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 10 Feb 2020 17:06:18 -0800 Subject: Sync tests: fix on_member_remove listener test The listener was changed earlier to simply set in_guild to False. This commit accounts for that in the test. --- tests/bot/cogs/sync/test_cog.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index f7e86f063..a8c79e0d3 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -208,23 +208,16 @@ class SyncCogListenerTests(SyncCogTestCase): self.bot.api_client.put.assert_not_called() def test_sync_cog_on_member_remove(self): - """A PUT request should be sent to set in_guild as False and update other fields.""" + """Member should patched to set in_guild as False.""" self.assertTrue(self.cog.on_member_remove.__cog_listener__) - roles = [helpers.MockRole(id=i) for i in (57, 22, 43)] # purposefully unsorted - member = helpers.MockMember(roles=roles) - + member = helpers.MockMember() asyncio.run(self.cog.on_member_remove(member)) - json_data = { - "avatar_hash": member.avatar, - "discriminator": int(member.discriminator), - "id": member.id, - "in_guild": False, - "name": member.name, - "roles": sorted(role.id for role in member.roles) - } - self.bot.api_client.put.assert_called_once_with(f"bot/users/{member.id}", json=json_data) + self.cog.patch_user.assert_called_once_with( + member.id, + updated_information={"in_guild": False} + ) def test_sync_cog_on_member_update_roles(self): """Members should be patched if their roles have changed.""" -- cgit v1.2.3 From 03b885ac9f8e0d30d4c38ad0f18a1d391c94765b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 11 Feb 2020 09:56:43 -0800 Subject: Sync tests: add a third role with a lower ID to on_member_update test This better ensures that roles are being sorted when patching. --- tests/bot/cogs/sync/test_cog.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index a8c79e0d3..88f6eb6cf 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -223,7 +223,8 @@ class SyncCogListenerTests(SyncCogTestCase): """Members should be patched if their roles have changed.""" self.assertTrue(self.cog.on_member_update.__cog_listener__) - before_roles = [helpers.MockRole(id=12), helpers.MockRole(id=30)] + # 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) after_member = helpers.MockMember(roles=before_roles[1:]) -- cgit v1.2.3 From a1cb58ac1e784db64d82a082be25df3d524bfc20 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 12 Feb 2020 08:33:19 -0800 Subject: Sync tests: test on_member_join Should PUT user's data or POST it if the user doesn't exist. ResponseCodeError should be re-raised if status code isn't a 404. A helper method was added to reduce code redundancy between the 2 tests. --- tests/bot/cogs/sync/test_cog.py | 52 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 88f6eb6cf..f66adfea1 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -297,6 +297,58 @@ class SyncCogListenerTests(SyncCogTestCase): else: self.cog.patch_user.assert_not_called() + 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)], + ) + + data = { + "avatar_hash": member.avatar, + "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: + asyncio.run(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 + + 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 = 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() + + def test_sync_cog_on_member_join_non_404(self): + """ResponseCodeError should be re-raised if status code isn't a 404.""" + self.assertRaises(ResponseCodeError, self.on_member_join_helper, self.response_error(500)) + self.bot.api_client.post.assert_not_called() + class SyncCogCommandTests(SyncCogTestCase, CommandTestCase): """Tests for the commands in the Sync cog.""" -- cgit v1.2.3 From b11e2eb365405dd63ac0fc3a830804b4b58e1ebc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 12 Feb 2020 08:52:02 -0800 Subject: Sync tests: use async_test decorator --- tests/bot/cogs/sync/test_base.py | 61 +++++++++++++++++------------ tests/bot/cogs/sync/test_cog.py | 81 +++++++++++++++++++++++---------------- tests/bot/cogs/sync/test_roles.py | 41 ++++++++++++-------- tests/bot/cogs/sync/test_users.py | 46 +++++++++++++--------- 4 files changed, 135 insertions(+), 94 deletions(-) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index ff11d911e..0539f5683 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -1,4 +1,3 @@ -import asyncio import unittest from unittest import mock @@ -62,16 +61,18 @@ class SyncerSendPromptTests(unittest.TestCase): return mock_channel, mock_message - def test_send_prompt_edits_and_returns_message(self): + @helpers.async_test + 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 = asyncio.run(self.syncer._send_prompt(msg)) + 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) - def test_send_prompt_gets_dev_core_channel(self): + @helpers.async_test + 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), @@ -81,31 +82,34 @@ class SyncerSendPromptTests(unittest.TestCase): for method, mock_ in subtests: with self.subTest(method=method, msg=mock_.__name__): mock_() - asyncio.run(self.syncer._send_prompt()) + await self.syncer._send_prompt() method.assert_called_once_with(constants.Channels.devcore) - def test_send_prompt_returns_None_if_channel_fetch_fails(self): + @helpers.async_test + 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 = asyncio.run(self.syncer._send_prompt()) + ret_val = await self.syncer._send_prompt() self.assertIsNone(ret_val) - def test_send_prompt_sends_and_returns_new_message_if_not_given(self): + @helpers.async_test + 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 = asyncio.run(self.syncer._send_prompt()) + 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) - def test_send_prompt_adds_reactions(self): + @helpers.async_test + async def test_send_prompt_adds_reactions(self): """The message should have reactions for confirmation added.""" extant_message = helpers.MockMessage() subtests = ( @@ -119,7 +123,7 @@ class SyncerSendPromptTests(unittest.TestCase): with self.subTest(msg=subtest_msg): _, mock_message = mock_() - asyncio.run(self.syncer._send_prompt(message_arg)) + 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) @@ -207,7 +211,8 @@ class SyncerConfirmationTests(unittest.TestCase): ret_val = self.syncer._reaction_check(*args) self.assertFalse(ret_val) - def test_wait_for_confirmation(self): + @helpers.async_test + 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), @@ -227,7 +232,7 @@ class SyncerConfirmationTests(unittest.TestCase): self.bot.wait_for.side_effect = side_effect # Call the function - actual_return = asyncio.run(self.syncer._wait_for_confirmation(member, message)) + actual_return = await self.syncer._wait_for_confirmation(member, message) # Perform assertions self.bot.wait_for.assert_called_once() @@ -253,7 +258,8 @@ class SyncerSyncTests(unittest.TestCase): self.bot = helpers.MockBot(user=helpers.MockMember(bot=True)) self.syncer = TestSyncer(self.bot) - def test_sync_respects_confirmation_result(self): + @helpers.async_test + async def test_sync_respects_confirmation_result(self): """The sync should abort if confirmation fails and continue if confirmed.""" mock_message = helpers.MockMessage() subtests = ( @@ -273,7 +279,7 @@ class SyncerSyncTests(unittest.TestCase): ) guild = helpers.MockGuild() - asyncio.run(self.syncer.sync(guild)) + await self.syncer.sync(guild) self.syncer._get_diff.assert_called_once_with(guild) self.syncer._get_confirmation_result.assert_called_once() @@ -283,7 +289,8 @@ class SyncerSyncTests(unittest.TestCase): else: self.syncer._sync.assert_not_called() - def test_sync_diff_size(self): + @helpers.async_test + async def test_sync_diff_size(self): """The diff size should be correctly calculated.""" subtests = ( (6, _Diff({1, 2}, {3, 4}, {5, 6})), @@ -299,13 +306,14 @@ class SyncerSyncTests(unittest.TestCase): self.syncer._get_confirmation_result = helpers.AsyncMock(return_value=(False, None)) guild = helpers.MockGuild() - asyncio.run(self.syncer.sync(guild)) + 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) - def test_sync_message_edited(self): + @helpers.async_test + 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), @@ -321,13 +329,14 @@ class SyncerSyncTests(unittest.TestCase): ) guild = helpers.MockGuild() - asyncio.run(self.syncer.sync(guild)) + await self.syncer.sync(guild) if should_edit: message.edit.assert_called_once() self.assertIn("content", message.edit.call_args[1]) - def test_sync_confirmation_context_redirect(self): + @helpers.async_test + 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 = ( @@ -343,7 +352,7 @@ class SyncerSyncTests(unittest.TestCase): self.syncer._get_confirmation_result = helpers.AsyncMock(return_value=(False, None)) guild = helpers.MockGuild() - asyncio.run(self.syncer.sync(guild, ctx)) + await self.syncer.sync(guild, ctx) if ctx is not None: ctx.send.assert_called_once() @@ -352,7 +361,8 @@ class SyncerSyncTests(unittest.TestCase): self.assertEqual(self.syncer._get_confirmation_result.call_args[0][1], author) self.assertEqual(self.syncer._get_confirmation_result.call_args[0][2], message) - def test_confirmation_result_small_diff(self): + @helpers.async_test + async def test_confirmation_result_small_diff(self): """Should always return True and the given message if the diff size is too small.""" self.syncer.MAX_DIFF = 3 author = helpers.MockMember() @@ -364,14 +374,15 @@ class SyncerSyncTests(unittest.TestCase): self.syncer._wait_for_confirmation = helpers.AsyncMock() coro = self.syncer._get_confirmation_result(size, author, expected_message) - result, actual_message = asyncio.run(coro) + 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() - def test_confirmation_result_large_diff(self): + @helpers.async_test + async def test_confirmation_result_large_diff(self): """Should return True if confirmed and False if _send_prompt fails or aborted.""" self.syncer.MAX_DIFF = 3 author = helpers.MockMember() @@ -389,7 +400,7 @@ class SyncerSyncTests(unittest.TestCase): self.syncer._wait_for_confirmation = helpers.AsyncMock(return_value=confirmed) coro = self.syncer._get_confirmation_result(4, author) - actual_result, actual_message = asyncio.run(coro) + 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) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index f66adfea1..98c9afc0d 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -1,4 +1,3 @@ -import asyncio import unittest from unittest import mock @@ -91,7 +90,8 @@ class SyncCogTests(SyncCogTestCase): sync_guild.assert_called_once_with() self.bot.loop.create_task.assert_called_once_with(mock_sync_guild_coro) - def test_sync_cog_sync_guild(self): + @helpers.async_test + 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): @@ -101,7 +101,7 @@ class SyncCogTests(SyncCogTestCase): self.bot.get_guild = mock.MagicMock(return_value=guild) - asyncio.run(self.cog.sync_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) @@ -113,29 +113,31 @@ class SyncCogTests(SyncCogTestCase): self.cog.role_syncer.sync.assert_called_once_with(guild) self.cog.user_syncer.sync.assert_called_once_with(guild) - def patch_user_helper(self, side_effect: BaseException) -> None: + 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} - asyncio.run(self.cog.patch_user(user_id, updated_information)) + 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, ) - def test_sync_cog_patch_user(self): + @helpers.async_test + 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): - self.patch_user_helper(side_effect) + await self.patch_user_helper(side_effect) - def test_sync_cog_patch_user_non_404(self): + @helpers.async_test + 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): - self.patch_user_helper(self.response_error(500)) + await self.patch_user_helper(self.response_error(500)) class SyncCogListenerTests(SyncCogTestCase): @@ -145,7 +147,8 @@ class SyncCogListenerTests(SyncCogTestCase): super().setUp() self.cog.patch_user = helpers.AsyncMock(spec_set=self.cog.patch_user) - def test_sync_cog_on_guild_role_create(self): + @helpers.async_test + 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__) @@ -157,20 +160,22 @@ class SyncCogListenerTests(SyncCogTestCase): "position": 23, } role = helpers.MockRole(**role_data) - asyncio.run(self.cog.on_guild_role_create(role)) + await self.cog.on_guild_role_create(role) self.bot.api_client.post.assert_called_once_with("bot/roles", json=role_data) - def test_sync_cog_on_guild_role_delete(self): + @helpers.async_test + 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) - asyncio.run(self.cog.on_guild_role_delete(role)) + await self.cog.on_guild_role_delete(role) self.bot.api_client.delete.assert_called_once_with("bot/roles/99") - def test_sync_cog_on_guild_role_update(self): + @helpers.async_test + 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__) @@ -197,7 +202,7 @@ class SyncCogListenerTests(SyncCogTestCase): before_role = helpers.MockRole(**role_data) after_role = helpers.MockRole(**after_role_data) - asyncio.run(self.cog.on_guild_role_update(before_role, after_role)) + await self.cog.on_guild_role_update(before_role, after_role) if should_put: self.bot.api_client.put.assert_called_once_with( @@ -207,19 +212,21 @@ class SyncCogListenerTests(SyncCogTestCase): else: self.bot.api_client.put.assert_not_called() - def test_sync_cog_on_member_remove(self): + @helpers.async_test + async def test_sync_cog_on_member_remove(self): """Member should patched to set in_guild as False.""" self.assertTrue(self.cog.on_member_remove.__cog_listener__) member = helpers.MockMember() - asyncio.run(self.cog.on_member_remove(member)) + await self.cog.on_member_remove(member) self.cog.patch_user.assert_called_once_with( member.id, updated_information={"in_guild": False} ) - def test_sync_cog_on_member_update_roles(self): + @helpers.async_test + 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__) @@ -228,12 +235,13 @@ class SyncCogListenerTests(SyncCogTestCase): before_member = helpers.MockMember(roles=before_roles) after_member = helpers.MockMember(roles=before_roles[1:]) - asyncio.run(self.cog.on_member_update(before_member, after_member)) + 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, updated_information=data) - def test_sync_cog_on_member_update_other(self): + @helpers.async_test + 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__) @@ -250,11 +258,12 @@ class SyncCogListenerTests(SyncCogTestCase): before_member = helpers.MockMember(**{attribute: old_value}) after_member = helpers.MockMember(**{attribute: new_value}) - asyncio.run(self.cog.on_member_update(before_member, after_member)) + await self.cog.on_member_update(before_member, after_member) self.cog.patch_user.assert_not_called() - def test_sync_cog_on_user_update(self): + @helpers.async_test + 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__) @@ -281,7 +290,7 @@ class SyncCogListenerTests(SyncCogTestCase): before_user = helpers.MockUser(**before_data) after_user = helpers.MockUser(**after_data) - asyncio.run(self.cog.on_user_update(before_user, after_user)) + await self.cog.on_user_update(before_user, after_user) if should_patch: self.cog.patch_user.assert_called_once() @@ -297,7 +306,7 @@ class SyncCogListenerTests(SyncCogTestCase): else: self.cog.patch_user.assert_not_called() - def on_member_join_helper(self, side_effect: Exception) -> dict: + 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. @@ -321,7 +330,7 @@ class SyncCogListenerTests(SyncCogTestCase): self.bot.api_client.put.side_effect = side_effect try: - asyncio.run(self.cog.on_member_join(member)) + await self.cog.on_member_join(member) except Exception: raise finally: @@ -332,38 +341,44 @@ class SyncCogListenerTests(SyncCogTestCase): return data - def test_sync_cog_on_member_join(self): + @helpers.async_test + 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 = self.on_member_join_helper(side_effect) + 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() - def test_sync_cog_on_member_join_non_404(self): + @helpers.async_test + async def test_sync_cog_on_member_join_non_404(self): """ResponseCodeError should be re-raised if status code isn't a 404.""" - self.assertRaises(ResponseCodeError, self.on_member_join_helper, self.response_error(500)) + with self.assertRaises(ResponseCodeError): + await self.on_member_join_helper(self.response_error(500)) + self.bot.api_client.post.assert_not_called() class SyncCogCommandTests(SyncCogTestCase, CommandTestCase): """Tests for the commands in the Sync cog.""" - def test_sync_roles_command(self): + @helpers.async_test + async def test_sync_roles_command(self): """sync() should be called on the RoleSyncer.""" ctx = helpers.MockContext() - asyncio.run(self.cog.sync_roles_command.callback(self.cog, ctx)) + await self.cog.sync_roles_command.callback(self.cog, ctx) self.cog.role_syncer.sync.assert_called_once_with(ctx.guild, ctx) - def test_sync_users_command(self): + @helpers.async_test + async def test_sync_users_command(self): """sync() should be called on the UserSyncer.""" ctx = helpers.MockContext() - asyncio.run(self.cog.sync_users_command.callback(self.cog, ctx)) + await self.cog.sync_users_command.callback(self.cog, ctx) self.cog.user_syncer.sync.assert_called_once_with(ctx.guild, ctx) diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index 8324b99cd..14fb2577a 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -1,4 +1,3 @@ -import asyncio import unittest from unittest import mock @@ -40,53 +39,58 @@ class RoleSyncerDiffTests(unittest.TestCase): return guild - def test_empty_diff_for_identical_roles(self): + @helpers.async_test + 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 = asyncio.run(self.syncer._get_diff(guild)) + actual_diff = await self.syncer._get_diff(guild) expected_diff = (set(), set(), set()) self.assertEqual(actual_diff, expected_diff) - def test_diff_for_updated_roles(self): + @helpers.async_test + 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 = asyncio.run(self.syncer._get_diff(guild)) + actual_diff = await self.syncer._get_diff(guild) expected_diff = (set(), {_Role(**updated_role)}, set()) self.assertEqual(actual_diff, expected_diff) - def test_diff_for_new_roles(self): + @helpers.async_test + 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 = asyncio.run(self.syncer._get_diff(guild)) + actual_diff = await self.syncer._get_diff(guild) expected_diff = ({_Role(**new_role)}, set(), set()) self.assertEqual(actual_diff, expected_diff) - def test_diff_for_deleted_roles(self): + @helpers.async_test + 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 = asyncio.run(self.syncer._get_diff(guild)) + actual_diff = await self.syncer._get_diff(guild) expected_diff = (set(), set(), {_Role(**deleted_role)}) self.assertEqual(actual_diff, expected_diff) - def test_diff_for_new_updated_and_deleted_roles(self): + @helpers.async_test + 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") @@ -99,7 +103,7 @@ class RoleSyncerDiffTests(unittest.TestCase): ] guild = self.get_guild(fake_role(), new, updated) - actual_diff = asyncio.run(self.syncer._get_diff(guild)) + actual_diff = await self.syncer._get_diff(guild) expected_diff = ({_Role(**new)}, {_Role(**updated)}, {_Role(**deleted)}) self.assertEqual(actual_diff, expected_diff) @@ -112,13 +116,14 @@ class RoleSyncerSyncTests(unittest.TestCase): self.bot = helpers.MockBot() self.syncer = RoleSyncer(self.bot) - def test_sync_created_roles(self): + @helpers.async_test + 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()) - asyncio.run(self.syncer._sync(diff)) + 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) @@ -127,13 +132,14 @@ class RoleSyncerSyncTests(unittest.TestCase): self.bot.api_client.put.assert_not_called() self.bot.api_client.delete.assert_not_called() - def test_sync_updated_roles(self): + @helpers.async_test + 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()) - asyncio.run(self.syncer._sync(diff)) + 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) @@ -142,13 +148,14 @@ class RoleSyncerSyncTests(unittest.TestCase): self.bot.api_client.post.assert_not_called() self.bot.api_client.delete.assert_not_called() - def test_sync_deleted_roles(self): + @helpers.async_test + 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) - asyncio.run(self.syncer._sync(diff)) + 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) diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index e9f9db2ea..421bf6bb6 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -1,4 +1,3 @@ -import asyncio import unittest from unittest import mock @@ -43,62 +42,68 @@ class UserSyncerDiffTests(unittest.TestCase): return guild - def test_empty_diff_for_no_users(self): + @helpers.async_test + 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 = asyncio.run(self.syncer._get_diff(guild)) + actual_diff = await self.syncer._get_diff(guild) expected_diff = (set(), set(), None) self.assertEqual(actual_diff, expected_diff) - def test_empty_diff_for_identical_users(self): + @helpers.async_test + 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 = asyncio.run(self.syncer._get_diff(guild)) + actual_diff = await self.syncer._get_diff(guild) expected_diff = (set(), set(), None) self.assertEqual(actual_diff, expected_diff) - def test_diff_for_updated_users(self): + @helpers.async_test + 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 = asyncio.run(self.syncer._get_diff(guild)) + actual_diff = await self.syncer._get_diff(guild) expected_diff = (set(), {_User(**updated_user)}, None) self.assertEqual(actual_diff, expected_diff) - def test_diff_for_new_users(self): + @helpers.async_test + 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 = asyncio.run(self.syncer._get_diff(guild)) + actual_diff = await self.syncer._get_diff(guild) expected_diff = ({_User(**new_user)}, set(), None) self.assertEqual(actual_diff, expected_diff) - def test_diff_sets_in_guild_false_for_leaving_users(self): + @helpers.async_test + 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 = asyncio.run(self.syncer._get_diff(guild)) + actual_diff = await self.syncer._get_diff(guild) expected_diff = (set(), {_User(**leaving_user)}, None) self.assertEqual(actual_diff, expected_diff) - def test_diff_for_new_updated_and_leaving_users(self): + @helpers.async_test + 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") @@ -107,17 +112,18 @@ class UserSyncerDiffTests(unittest.TestCase): 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 = asyncio.run(self.syncer._get_diff(guild)) + 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) - def test_empty_diff_for_db_users_not_in_guild(self): + @helpers.async_test + 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 = asyncio.run(self.syncer._get_diff(guild)) + actual_diff = await self.syncer._get_diff(guild) expected_diff = (set(), set(), None) self.assertEqual(actual_diff, expected_diff) @@ -130,13 +136,14 @@ class UserSyncerSyncTests(unittest.TestCase): self.bot = helpers.MockBot() self.syncer = UserSyncer(self.bot) - def test_sync_created_users(self): + @helpers.async_test + 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) - asyncio.run(self.syncer._sync(diff)) + 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) @@ -145,13 +152,14 @@ class UserSyncerSyncTests(unittest.TestCase): self.bot.api_client.put.assert_not_called() self.bot.api_client.delete.assert_not_called() - def test_sync_updated_users(self): + @helpers.async_test + 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) - asyncio.run(self.syncer._sync(diff)) + 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) -- cgit v1.2.3 From 22a55534ef13990815a6f69d361e2a12693075d5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 12 Feb 2020 09:16:46 -0800 Subject: Tests: fix unawaited error for MockAPIClient This error is due to the use of an actual instance of APIClient as the spec for the mock. recreate() is called in __init__ which in turn creates a task for the _create_session coroutine. The approach to the solution is to use the type for the spec rather than and instance, thus avoiding any call of __init__. However, without an instance, instance attributes will not be included in the spec. Therefore, they are defined as class attributes on the actual APIClient class definition and given default values. Alternatively, a subclass of APIClient could have been made in the tests.helpers module to define those class attributes. However, it seems easier to maintain if the attributes are in the original class definition. --- bot/api.py | 5 ++++- tests/helpers.py | 6 +----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/bot/api.py b/bot/api.py index a9d2baa4d..d5880ba18 100644 --- a/bot/api.py +++ b/bot/api.py @@ -32,6 +32,9 @@ class ResponseCodeError(ValueError): class APIClient: """Django Site API wrapper.""" + session: Optional[aiohttp.ClientSession] = None + loop: asyncio.AbstractEventLoop = None + def __init__(self, loop: asyncio.AbstractEventLoop, **kwargs): auth_headers = { 'Authorization': f"Token {Keys.site_api}" @@ -42,7 +45,7 @@ class APIClient: else: kwargs['headers'] = auth_headers - self.session: Optional[aiohttp.ClientSession] = None + self.session = None self.loop = loop self._ready = asyncio.Event(loop=loop) diff --git a/tests/helpers.py b/tests/helpers.py index a40673bb9..9d9dd5da6 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -337,10 +337,6 @@ class MockUser(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): self.mention = f"@{self.name}" -# Create an APIClient instance to get a realistic MagicMock of `bot.api.APIClient` -api_client_instance = APIClient(loop=unittest.mock.MagicMock()) - - class MockAPIClient(CustomMockMixin, unittest.mock.MagicMock): """ A MagicMock subclass to mock APIClient objects. @@ -350,7 +346,7 @@ class MockAPIClient(CustomMockMixin, unittest.mock.MagicMock): """ def __init__(self, **kwargs) -> None: - super().__init__(spec_set=api_client_instance, **kwargs) + super().__init__(spec_set=APIClient, **kwargs) # Create a Bot instance to get a realistic MagicMock of `discord.ext.commands.Bot` -- cgit v1.2.3 From 419a8e616e6e5a185769764e755ce0592ef8e72f Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Wed, 12 Feb 2020 15:56:56 -0500 Subject: Add reminder ID to footer of confirmation message --- 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 45bf9a8f4..7b2f8d31d 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -56,12 +56,14 @@ class Reminders(Scheduler, Cog): self.schedule_task(loop, reminder["id"], reminder) @staticmethod - async def _send_confirmation(ctx: Context, on_success: str) -> None: + async def _send_confirmation(ctx: Context, on_success: str, reminder_id: str) -> None: """Send an embed confirming the reminder change was made successfully.""" embed = Embed() embed.colour = Colour.green() embed.title = random.choice(POSITIVE_REPLIES) embed.description = on_success + embed.set_footer(text=f"ID {reminder_id}") + await ctx.send(embed=embed) async def _scheduled_task(self, reminder: dict) -> None: @@ -182,7 +184,8 @@ class Reminders(Scheduler, Cog): # Confirm to the user that it worked. await self._send_confirmation( ctx, - on_success=f"Your reminder will arrive in {humanize_delta(relativedelta(expiration, now))}!" + on_success=f"Your reminder will arrive in {humanize_delta(relativedelta(expiration, now))}!", + reminder_id=reminder["id"], ) loop = asyncio.get_event_loop() @@ -261,7 +264,7 @@ class Reminders(Scheduler, Cog): # Send a confirmation message to the channel await self._send_confirmation( - ctx, on_success="That reminder has been edited successfully!" + ctx, on_success="That reminder has been edited successfully!", reminder_id=id_ ) await self._reschedule_reminder(reminder) @@ -277,7 +280,7 @@ class Reminders(Scheduler, Cog): # Send a confirmation message to the channel await self._send_confirmation( - ctx, on_success="That reminder has been edited successfully!" + ctx, on_success="That reminder has been edited successfully!", reminder_id=id_ ) await self._reschedule_reminder(reminder) @@ -286,7 +289,7 @@ class Reminders(Scheduler, Cog): """Delete one of your active reminders.""" await self._delete_reminder(id_) await self._send_confirmation( - ctx, on_success="That reminder has been deleted successfully!" + ctx, on_success="That reminder has been deleted successfully!", reminder_id=id_ ) -- cgit v1.2.3 From b1c1f8c11ec09d264afa8095fa6eb13639685bc9 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Wed, 12 Feb 2020 16:52:39 -0500 Subject: Add reminder target datetime to footer of confirmation message --- bot/cogs/reminders.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 7b2f8d31d..715c2d89b 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -56,13 +56,20 @@ class Reminders(Scheduler, Cog): self.schedule_task(loop, reminder["id"], reminder) @staticmethod - async def _send_confirmation(ctx: Context, on_success: str, reminder_id: str) -> None: + async def _send_confirmation( + ctx: Context, on_success: str, reminder_id: str, delivery_dt: Optional[datetime] + ) -> None: """Send an embed confirming the reminder change was made successfully.""" embed = Embed() embed.colour = Colour.green() embed.title = random.choice(POSITIVE_REPLIES) embed.description = on_success - embed.set_footer(text=f"ID {reminder_id}") + + if delivery_dt: + embed.set_footer(text=f"ID: {reminder_id}, Due: {delivery_dt.strftime('%Y-%m-%dT%H:%M:%S')}") + else: + # Reminder deletion will have a `None` `delivery_dt` + embed.set_footer(text=f"ID: {reminder_id}") await ctx.send(embed=embed) @@ -186,6 +193,7 @@ class Reminders(Scheduler, Cog): ctx, on_success=f"Your reminder will arrive in {humanize_delta(relativedelta(expiration, now))}!", reminder_id=reminder["id"], + delivery_dt=expiration, ) loop = asyncio.get_event_loop() @@ -264,7 +272,7 @@ class Reminders(Scheduler, Cog): # Send a confirmation message to the channel await self._send_confirmation( - ctx, on_success="That reminder has been edited successfully!", reminder_id=id_ + ctx, on_success="That reminder has been edited successfully!", reminder_id=id_, delivery_dt=expiration ) await self._reschedule_reminder(reminder) @@ -278,9 +286,12 @@ class Reminders(Scheduler, Cog): json={'content': content} ) + # Parse the reminder expiration back into a datetime for the confirmation message + expiration = datetime.fromisoformat(reminder['expiration'][:-1]) + # Send a confirmation message to the channel await self._send_confirmation( - ctx, on_success="That reminder has been edited successfully!", reminder_id=id_ + ctx, on_success="That reminder has been edited successfully!", reminder_id=id_, delivery_dt=expiration ) await self._reschedule_reminder(reminder) @@ -289,7 +300,7 @@ class Reminders(Scheduler, Cog): """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_ + ctx, on_success="That reminder has been deleted successfully!", reminder_id=id_, delivery_dt=None ) -- cgit v1.2.3 From ee930bdde1e99cb9e2880e86dd647a42e90d2580 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Wed, 12 Feb 2020 17:14:49 -0500 Subject: Expand reminder channel whitelist to dev-contrib for non-staff Add channel ID to config files --- bot/cogs/reminders.py | 2 +- bot/constants.py | 1 + config-default.yml | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 715c2d89b..57a74270a 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -20,7 +20,7 @@ from bot.utils.time import humanize_delta, wait_until log = logging.getLogger(__name__) -WHITELISTED_CHANNELS = (Channels.bot,) +WHITELISTED_CHANNELS = (Channels.bot, Channels.devcontrib) MAXIMUM_REMINDERS = 5 diff --git a/bot/constants.py b/bot/constants.py index fe8e57322..e2704bfa8 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -365,6 +365,7 @@ class Channels(metaclass=YAMLGetter): bot: int checkpoint_test: int defcon: int + devcontrib: int devlog: int devtest: int esoteric: int diff --git a/config-default.yml b/config-default.yml index fda14b511..ab610d618 100644 --- a/config-default.yml +++ b/config-default.yml @@ -121,6 +121,7 @@ guild: bot: 267659945086812160 checkpoint_test: 422077681434099723 defcon: &DEFCON 464469101889454091 + devcontrib: 635950537262759947 devlog: &DEVLOG 622895325144940554 devtest: &DEVTEST 414574275865870337 esoteric: 470884583684964352 -- cgit v1.2.3 From 5086ca94cb45e411f6463fbe338ba0d6b2192be5 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Sat, 15 Feb 2020 10:38:41 -0500 Subject: Styling & refactors from review * Refactor confirmation embed footer string generation to be more concise * Multiline long method calls * Refactor humanized delta f string generation for readability * Switch from `datetime.isoformat` to `dateutils.parser.isoparse` to align with changes elsewhere in the codebase (should be more robust) * Shift reminder channel whitelist to constants Co-Authored-By: Mark --- bot/cogs/reminders.py | 39 +++++++++++++++++++++++++-------------- bot/constants.py | 2 +- config-default.yml | 5 +++-- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 57a74270a..efeafa0bc 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -6,12 +6,13 @@ from datetime import datetime, timedelta from operator import itemgetter from typing import Optional +from dateutil.parser import isoparse from dateutil.relativedelta import relativedelta from discord import Colour, Embed, Message from discord.ext.commands import Cog, Context, group from bot.bot import Bot -from bot.constants import Channels, Icons, NEGATIVE_REPLIES, POSITIVE_REPLIES, STAFF_ROLES +from bot.constants import Guild, Icons, NEGATIVE_REPLIES, POSITIVE_REPLIES, STAFF_ROLES from bot.converters import Duration from bot.pagination import LinePaginator from bot.utils.checks import without_role_check @@ -20,7 +21,7 @@ from bot.utils.time import humanize_delta, wait_until log = logging.getLogger(__name__) -WHITELISTED_CHANNELS = (Channels.bot, Channels.devcontrib) +WHITELISTED_CHANNELS = Guild.reminder_whitelist MAXIMUM_REMINDERS = 5 @@ -45,13 +46,12 @@ class Reminders(Scheduler, Cog): loop = asyncio.get_event_loop() for reminder in response: - remind_at = datetime.fromisoformat(reminder['expiration'][:-1]) + 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_task(loop, reminder["id"], reminder) @@ -65,18 +65,19 @@ class Reminders(Scheduler, Cog): embed.title = random.choice(POSITIVE_REPLIES) embed.description = on_success + footer_str = f"ID: {reminder_id}" if delivery_dt: - embed.set_footer(text=f"ID: {reminder_id}, Due: {delivery_dt.strftime('%Y-%m-%dT%H:%M:%S')}") - else: # Reminder deletion will have a `None` `delivery_dt` - embed.set_footer(text=f"ID: {reminder_id}") + 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) async def _scheduled_task(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 = datetime.fromisoformat(reminder['expiration'][:-1]) + reminder_datetime = isoparse(reminder['expiration']).replace(tzinfo=None) # Send the reminder message once the desired duration has passed await wait_until(reminder_datetime) @@ -187,11 +188,12 @@ class Reminders(Scheduler, Cog): ) now = datetime.utcnow() - timedelta(seconds=1) + humanized_delta = humanize_delta(relativedelta(expiration, now)) # Confirm to the user that it worked. await self._send_confirmation( ctx, - on_success=f"Your reminder will arrive in {humanize_delta(relativedelta(expiration, now))}!", + on_success=f"Your reminder will arrive in {humanized_delta}!", reminder_id=reminder["id"], delivery_dt=expiration, ) @@ -223,7 +225,7 @@ class Reminders(Scheduler, Cog): for content, remind_at, id_ in reminders: # Parse and humanize the time, make it pretty :D - remind_datetime = datetime.fromisoformat(remind_at[:-1]) + remind_datetime = isoparse(remind_at).replace(tzinfo=None) time = humanize_delta(relativedelta(remind_datetime, now)) text = textwrap.dedent(f""" @@ -272,7 +274,10 @@ class Reminders(Scheduler, Cog): # 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 + ctx, + on_success="That reminder has been edited successfully!", + reminder_id=id_, + delivery_dt=expiration, ) await self._reschedule_reminder(reminder) @@ -287,11 +292,14 @@ class Reminders(Scheduler, Cog): ) # Parse the reminder expiration back into a datetime for the confirmation message - expiration = datetime.fromisoformat(reminder['expiration'][:-1]) + 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 + ctx, + on_success="That reminder has been edited successfully!", + reminder_id=id_, + delivery_dt=expiration, ) await self._reschedule_reminder(reminder) @@ -300,7 +308,10 @@ class Reminders(Scheduler, Cog): """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 + ctx, + on_success="That reminder has been deleted successfully!", + reminder_id=id_, + delivery_dt=None, ) diff --git a/bot/constants.py b/bot/constants.py index e2704bfa8..e9990307a 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -433,7 +433,7 @@ class Guild(metaclass=YAMLGetter): id: int ignored: List[int] staff_channels: List[int] - + reminder_whitelist: List[int] class Keys(metaclass=YAMLGetter): section = "keys" diff --git a/config-default.yml b/config-default.yml index ab610d618..3de7c6ba4 100644 --- a/config-default.yml +++ b/config-default.yml @@ -118,10 +118,10 @@ guild: announcements: 354619224620138496 attachment_log: &ATTCH_LOG 649243850006855680 big_brother_logs: &BBLOGS 468507907357409333 - bot: 267659945086812160 + bot: &BOT_CMD 267659945086812160 checkpoint_test: 422077681434099723 defcon: &DEFCON 464469101889454091 - devcontrib: 635950537262759947 + devcontrib: &DEV_CONTRIB 635950537262759947 devlog: &DEVLOG 622895325144940554 devtest: &DEVTEST 414574275865870337 esoteric: 470884583684964352 @@ -156,6 +156,7 @@ guild: staff_channels: [*ADMINS, *ADMIN_SPAM, *MOD_SPAM, *MODS, *HELPERS, *ORGANISATION, *DEFCON] ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG, *ADMINS_VOICE, *STAFF_VOICE, *ATTCH_LOG] + reminder_whitelist: [*BOT_CMD, *DEV_CONTRIB] roles: admin: &ADMIN_ROLE 267628507062992896 -- cgit v1.2.3 From d2451bd8fd3e3efc43de7146958d5f9f7d90723d Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Sat, 15 Feb 2020 11:20:56 -0500 Subject: Add full capture of reason string to superstarify invocation --- bot/cogs/moderation/superstarify.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 050c847ac..c41874a95 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -109,7 +109,8 @@ class Superstarify(InfractionScheduler, Cog): ctx: Context, member: Member, duration: Expiry, - reason: str = None + *, + reason: str = None, ) -> None: """ Temporarily force a random superstar name (like Taylor Swift) to be the user's nickname. -- cgit v1.2.3 From e82ccfe032a6637a064c41e4b7b66107a84e0b36 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Sat, 15 Feb 2020 11:24:17 -0500 Subject: Add "cancel" as a reminder delete alias --- 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 efeafa0bc..f39ad856a 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -303,7 +303,7 @@ class Reminders(Scheduler, Cog): ) await self._reschedule_reminder(reminder) - @remind_group.command("delete", aliases=("remove",)) + @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_) -- cgit v1.2.3 From bad164b8af9e0db0d5d8b1beaa8f2e6e3fdc4799 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Sat, 15 Feb 2020 11:37:23 -0500 Subject: Add missed signature reformat from review Co-Authored-By: Mark --- bot/cogs/reminders.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index f39ad856a..ff803baf8 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -57,7 +57,10 @@ class Reminders(Scheduler, Cog): @staticmethod async def _send_confirmation( - ctx: Context, on_success: str, reminder_id: str, delivery_dt: Optional[datetime] + ctx: Context, + on_success: str, + reminder_id: str, + delivery_dt: Optional[datetime], ) -> None: """Send an embed confirming the reminder change was made successfully.""" embed = Embed() -- cgit v1.2.3 From 6f25814aee1794fdabce6fef97d0d776121d5535 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 11:41:56 -0800 Subject: Moderation: fix member not found error not being shown --- bot/cogs/moderation/infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index f4e296df9..9ea17b2b3 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -313,6 +313,6 @@ class Infractions(InfractionScheduler, commands.Cog): 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: + if discord.User in error.converters or discord.Member in error.converters: await ctx.send(str(error.errors[0])) error.handled = True -- cgit v1.2.3 From a83d2683f72a750b1946df913749a5c4257ebb16 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 11:52:54 -0800 Subject: Error handler: create separate function to handle CheckFailure --- bot/cogs/error_handler.py | 44 +++++++++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 52893b2ee..bd47eecf8 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -100,21 +100,9 @@ class ErrorHandler(Cog): f"Command {command} invoked by {ctx.message.author} with error " f"{e.__class__.__name__}: {e}" ) - elif isinstance(e, NoPrivateMessage): - await ctx.send("Sorry, this command can't be used in a private message!") - elif isinstance(e, BotMissingPermissions): - await ctx.send(f"Sorry, it looks like I don't have the permissions I need to do that.") - log.warning( - f"The bot is missing permissions to execute command {command}: {e.missing_perms}" - ) - elif isinstance(e, MissingPermissions): - log.debug( - f"{ctx.message.author} is missing permissions to invoke command {command}: " - f"{e.missing_perms}" - ) - elif isinstance(e, InChannelCheckFailure): - await ctx.send(e) - elif isinstance(e, (CheckFailure, CommandOnCooldown, DisabledCommand)): + elif isinstance(e, CheckFailure): + await self.handle_check_failure(ctx, e) + elif isinstance(e, (CommandOnCooldown, DisabledCommand)): log.debug( f"Command {command} invoked by {ctx.message.author} with error " f"{e.__class__.__name__}: {e}" @@ -138,8 +126,34 @@ class ErrorHandler(Cog): else: await self.handle_unexpected_error(ctx, e.original) else: + # MaxConcurrencyReached, ExtensionError await self.handle_unexpected_error(ctx, e) + @staticmethod + async def handle_check_failure(ctx: Context, e: CheckFailure) -> None: + """Handle CheckFailure exceptions and its children.""" + command = ctx.command + + if isinstance(e, NoPrivateMessage): + await ctx.send("Sorry, this command can't be used in a private message!") + elif isinstance(e, BotMissingPermissions): + await ctx.send(f"Sorry, it looks like I don't have the permissions I need to do that.") + log.warning( + f"The bot is missing permissions to execute command {command}: {e.missing_perms}" + ) + elif isinstance(e, MissingPermissions): + log.debug( + f"{ctx.message.author} is missing permissions to invoke command {command}: " + f"{e.missing_perms}" + ) + elif isinstance(e, InChannelCheckFailure): + await ctx.send(e) + else: + log.debug( + f"Command {command} invoked by {ctx.message.author} with error " + f"{e.__class__.__name__}: {e}" + ) + @staticmethod async def handle_unexpected_error(ctx: Context, e: CommandError) -> None: """Generic handler for errors without an explicit handler.""" -- cgit v1.2.3 From eab4b16ccb2d5b6f3d0a8765e8741fe88fb03e27 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 11:58:48 -0800 Subject: Error handler: create separate function to handle ResponseCodeError --- bot/cogs/error_handler.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index bd47eecf8..97124cb15 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -109,20 +109,7 @@ class ErrorHandler(Cog): ) elif isinstance(e, CommandInvokeError): if isinstance(e.original, ResponseCodeError): - status = e.original.response.status - - if status == 404: - await ctx.send("There does not seem to be anything matching your query.") - elif status == 400: - content = await e.original.response.json() - log.debug(f"API responded with 400 for command {command}: %r.", content) - await ctx.send("According to the API, your request is malformed.") - elif 500 <= status < 600: - await ctx.send("Sorry, there seems to be an internal issue with the API.") - log.warning(f"API responded with {status} for command {command}") - else: - await ctx.send(f"Got an unexpected status code from the API (`{status}`).") - log.warning(f"Unexpected API response for command {command}: {status}") + await self.handle_api_error(ctx, e.original) else: await self.handle_unexpected_error(ctx, e.original) else: @@ -154,6 +141,22 @@ class ErrorHandler(Cog): f"{e.__class__.__name__}: {e}" ) + @staticmethod + async def handle_api_error(ctx: Context, e: ResponseCodeError) -> None: + """Handle ResponseCodeError exceptions.""" + if e.status == 404: + await ctx.send("There does not seem to be anything matching your query.") + 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.") + 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}") + 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}") + @staticmethod async def handle_unexpected_error(ctx: Context, e: CommandError) -> None: """Generic handler for errors without an explicit handler.""" -- cgit v1.2.3 From 29e3c3e46242866820b9c4461378ed4b2e3afb47 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 12:09:24 -0800 Subject: Error handler: log unhandled exceptions instead of re-raising --- bot/cogs/error_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 97124cb15..5eef045e8 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -165,9 +165,9 @@ class ErrorHandler(Cog): f"```{e.__class__.__name__}: {e}```" ) log.error( - f"Error executing command invoked by {ctx.message.author}: {ctx.message.content}" + f"Error executing command invoked by {ctx.message.author}: {ctx.message.content}", + exc_info=e ) - raise e def setup(bot: Bot) -> None: -- cgit v1.2.3 From 806c69f78c5751f6dc93bd8dcc6fff95436fe0ed Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 12:19:52 -0800 Subject: Error handler: move tag retrieval to a separate function --- bot/cogs/error_handler.py | 46 ++++++++++++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 5eef045e8..7078d425d 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -72,24 +72,8 @@ class ErrorHandler(Cog): # Try to look for a tag with the command's name if the command isn't found. if isinstance(e, CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"): - if not ctx.channel.id == Channels.verification: - 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 CommandError as tag_error: - log.debug(log_msg) - await self.on_command_error(ctx, tag_error) - return - - # Return to not raise the exception - with contextlib.suppress(ResponseCodeError): - await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with) - return + if ctx.channel.id != Channels.verification: + await self.try_get_tag(ctx) elif isinstance(e, BadArgument): await ctx.send(f"Bad argument: {e}\n") await ctx.invoke(*help_command) @@ -116,6 +100,32 @@ class ErrorHandler(Cog): # MaxConcurrencyReached, ExtensionError await self.handle_unexpected_error(ctx, e) + 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 CommandError as tag_error: + log.debug(log_msg) + await self.on_command_error(ctx, tag_error) + return + + # Return to not raise the exception + with contextlib.suppress(ResponseCodeError): + await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with) + return + @staticmethod async def handle_check_failure(ctx: Context, e: CheckFailure) -> None: """Handle CheckFailure exceptions and its children.""" -- cgit v1.2.3 From fb30fb1427fa26d6cfd54fdb6a80e4e7552d808f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 12:31:42 -0800 Subject: Error handler: move help command retrieval to a separate function --- bot/cogs/error_handler.py | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 7078d425d..6a0aef13e 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -1,10 +1,12 @@ import contextlib import logging +import typing as t from discord.ext.commands import ( BadArgument, BotMissingPermissions, CheckFailure, + Command, CommandError, CommandInvokeError, CommandNotFound, @@ -53,18 +55,9 @@ class ErrorHandler(Cog): 10. Otherwise, handling is deferred to `handle_unexpected_error` """ command = ctx.command - parent = None - if command is not None: - parent = command.parent - - # Retrieve the help command for the invoked command. - if parent and command: - help_command = (self.bot.get_command("help"), parent.name, command.name) - elif command: - help_command = (self.bot.get_command("help"), command.name) - else: - help_command = (self.bot.get_command("help"),) + # TODO: use ctx.send_help() once PR #519 is merged. + help_command = await self.get_help_command(command) if hasattr(e, "handled"): log.trace(f"Command {command} had its error already handled locally; ignoring.") @@ -100,6 +93,20 @@ class ErrorHandler(Cog): # MaxConcurrencyReached, ExtensionError await self.handle_unexpected_error(ctx, e) + async def get_help_command(self, command: t.Optional[Command]) -> t.Tuple: + """Return the help command invocation args to display help for `command`.""" + parent = None + if command is not None: + parent = command.parent + + # Retrieve the help command for the invoked command. + if parent and command: + return self.bot.get_command("help"), parent.name, command.name + elif command: + return self.bot.get_command("help"), command.name + else: + return self.bot.get_command("help") + async def try_get_tag(self, ctx: Context) -> None: """ Attempt to display a tag by interpreting the command name as a tag name. -- cgit v1.2.3 From d263f948e57a71e23cf4e04d678a880a130f3884 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 12:44:58 -0800 Subject: Error handler: create separate function to handle UserInputError --- bot/cogs/error_handler.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 6a0aef13e..c7758d946 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -56,9 +56,6 @@ class ErrorHandler(Cog): """ command = ctx.command - # TODO: use ctx.send_help() once PR #519 is merged. - help_command = await self.get_help_command(command) - if hasattr(e, "handled"): log.trace(f"Command {command} had its error already handled locally; ignoring.") return @@ -67,16 +64,8 @@ class ErrorHandler(Cog): if isinstance(e, CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"): if ctx.channel.id != Channels.verification: await self.try_get_tag(ctx) - elif isinstance(e, BadArgument): - await ctx.send(f"Bad argument: {e}\n") - await ctx.invoke(*help_command) elif isinstance(e, UserInputError): - await ctx.send("Something about your input seems off. Check the arguments:") - await ctx.invoke(*help_command) - log.debug( - f"Command {command} invoked by {ctx.message.author} with error " - f"{e.__class__.__name__}: {e}" - ) + await self.handle_user_input_error(ctx, e) elif isinstance(e, CheckFailure): await self.handle_check_failure(ctx, e) elif isinstance(e, (CommandOnCooldown, DisabledCommand)): @@ -133,6 +122,22 @@ class ErrorHandler(Cog): await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with) return + async def handle_user_input_error(self, ctx: Context, e: UserInputError) -> None: + """Handle UserInputError exceptions and its children.""" + # TODO: use ctx.send_help() once PR #519 is merged. + help_command = await self.get_help_command(ctx.command) + + if isinstance(e, BadArgument): + await ctx.send(f"Bad argument: {e}\n") + await ctx.invoke(*help_command) + else: + await ctx.send("Something about your input seems off. Check the arguments:") + await ctx.invoke(*help_command) + log.debug( + f"Command {ctx.command} invoked by {ctx.message.author} with error " + f"{e.__class__.__name__}: {e}" + ) + @staticmethod async def handle_check_failure(ctx: Context, e: CheckFailure) -> None: """Handle CheckFailure exceptions and its children.""" -- cgit v1.2.3 From dbd879e715fe9eadee33d098282cb7b4d941df26 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 13:09:05 -0800 Subject: Error handler: simplify error imports Import the errors module and qualify the error types with it rather than importing a large list of error types. --- bot/cogs/error_handler.py | 44 +++++++++++++++----------------------------- 1 file changed, 15 insertions(+), 29 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index c7758d946..c65ada344 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -2,21 +2,7 @@ import contextlib import logging import typing as t -from discord.ext.commands import ( - BadArgument, - BotMissingPermissions, - CheckFailure, - Command, - CommandError, - CommandInvokeError, - CommandNotFound, - CommandOnCooldown, - DisabledCommand, - MissingPermissions, - NoPrivateMessage, - UserInputError, -) -from discord.ext.commands import Cog, Context +from discord.ext.commands import Cog, Command, Context, errors from bot.api import ResponseCodeError from bot.bot import Bot @@ -33,7 +19,7 @@ class ErrorHandler(Cog): self.bot = bot @Cog.listener() - async def on_command_error(self, ctx: Context, e: CommandError) -> None: + async def on_command_error(self, ctx: Context, e: errors.CommandError) -> None: """ Provide generic command error handling. @@ -61,19 +47,19 @@ class ErrorHandler(Cog): return # Try to look for a tag with the command's name if the command isn't found. - if isinstance(e, CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"): + if isinstance(e, errors.CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"): if ctx.channel.id != Channels.verification: await self.try_get_tag(ctx) - elif isinstance(e, UserInputError): + elif isinstance(e, errors.UserInputError): await self.handle_user_input_error(ctx, e) - elif isinstance(e, CheckFailure): + elif isinstance(e, errors.CheckFailure): await self.handle_check_failure(ctx, e) - elif isinstance(e, (CommandOnCooldown, DisabledCommand)): + elif isinstance(e, (errors.CommandOnCooldown, errors.DisabledCommand)): log.debug( f"Command {command} invoked by {ctx.message.author} with error " f"{e.__class__.__name__}: {e}" ) - elif isinstance(e, CommandInvokeError): + elif isinstance(e, errors.CommandInvokeError): if isinstance(e.original, ResponseCodeError): await self.handle_api_error(ctx, e.original) else: @@ -112,7 +98,7 @@ class ErrorHandler(Cog): if not await tags_get_command.can_run(ctx): log.debug(log_msg) return - except CommandError as tag_error: + except errors.CommandError as tag_error: log.debug(log_msg) await self.on_command_error(ctx, tag_error) return @@ -122,12 +108,12 @@ class ErrorHandler(Cog): await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with) return - async def handle_user_input_error(self, ctx: Context, e: UserInputError) -> None: + async def handle_user_input_error(self, ctx: Context, e: errors.UserInputError) -> None: """Handle UserInputError exceptions and its children.""" # TODO: use ctx.send_help() once PR #519 is merged. help_command = await self.get_help_command(ctx.command) - if isinstance(e, BadArgument): + if isinstance(e, errors.BadArgument): await ctx.send(f"Bad argument: {e}\n") await ctx.invoke(*help_command) else: @@ -139,18 +125,18 @@ class ErrorHandler(Cog): ) @staticmethod - async def handle_check_failure(ctx: Context, e: CheckFailure) -> None: + async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None: """Handle CheckFailure exceptions and its children.""" command = ctx.command - if isinstance(e, NoPrivateMessage): + if isinstance(e, errors.NoPrivateMessage): await ctx.send("Sorry, this command can't be used in a private message!") - elif isinstance(e, BotMissingPermissions): + elif isinstance(e, errors.BotMissingPermissions): await ctx.send(f"Sorry, it looks like I don't have the permissions I need to do that.") log.warning( f"The bot is missing permissions to execute command {command}: {e.missing_perms}" ) - elif isinstance(e, MissingPermissions): + elif isinstance(e, errors.MissingPermissions): log.debug( f"{ctx.message.author} is missing permissions to invoke command {command}: " f"{e.missing_perms}" @@ -180,7 +166,7 @@ class ErrorHandler(Cog): log.warning(f"Unexpected API response for command {ctx.command}: {e.status}") @staticmethod - async def handle_unexpected_error(ctx: Context, e: CommandError) -> None: + async def handle_unexpected_error(ctx: Context, e: errors.CommandError) -> None: """Generic handler for errors without an explicit handler.""" await ctx.send( f"Sorry, an unexpected error occurred. Please let us know!\n\n" -- cgit v1.2.3 From d2f94f4c1280716e32e13611f6c778f9d9d4efd3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 13:15:55 -0800 Subject: Error handler: handle MissingRequiredArgument Send a message indicating which argument is missing. --- bot/cogs/error_handler.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index c65ada344..ffb36d10a 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -113,7 +113,10 @@ class ErrorHandler(Cog): # TODO: use ctx.send_help() once PR #519 is merged. help_command = await self.get_help_command(ctx.command) - if isinstance(e, errors.BadArgument): + if isinstance(e, errors.MissingRequiredArgument): + await ctx.send(f"Missing required argument `{e.param.name}`.") + await ctx.invoke(*help_command) + elif isinstance(e, errors.BadArgument): await ctx.send(f"Bad argument: {e}\n") await ctx.invoke(*help_command) else: -- cgit v1.2.3 From 6fa0ba18a6b4daa265e6716f9d360117378c67ab Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 13:17:03 -0800 Subject: Error handler: handle TooManyArguments Send a message specifying the error reason. --- 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 ffb36d10a..5cf95e71a 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -116,6 +116,9 @@ class ErrorHandler(Cog): if isinstance(e, errors.MissingRequiredArgument): await ctx.send(f"Missing required argument `{e.param.name}`.") await ctx.invoke(*help_command) + elif isinstance(e, errors.TooManyArguments): + await ctx.send(f"Too many arguments provided.") + await ctx.invoke(*help_command) elif isinstance(e, errors.BadArgument): await ctx.send(f"Bad argument: {e}\n") await ctx.invoke(*help_command) -- cgit v1.2.3 From 8cdbec386d50e5866dcfd7cc0aeee359bb182317 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 13:23:56 -0800 Subject: Error handler: handle BadUnionArgument Send a message specifying the parameter name, the converters used, and the last error message from the converters. --- bot/cogs/error_handler.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 5cf95e71a..d67261fc6 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -122,6 +122,8 @@ class ErrorHandler(Cog): elif isinstance(e, errors.BadArgument): await ctx.send(f"Bad argument: {e}\n") await ctx.invoke(*help_command) + elif isinstance(e, errors.BadUnionArgument): + await ctx.send(f"Bad argument: {e}\n```{e.errors[-1]}```") else: await ctx.send("Something about your input seems off. Check the arguments:") await ctx.invoke(*help_command) -- cgit v1.2.3 From 4116aca0218138dd1a97db39b942a886945fa05b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 17:23:43 -0800 Subject: Error handler: handle ArgumentParsingError Simply send the error message with the help command. --- bot/cogs/error_handler.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index d67261fc6..07b93283d 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -124,6 +124,8 @@ class ErrorHandler(Cog): await ctx.invoke(*help_command) elif isinstance(e, errors.BadUnionArgument): await ctx.send(f"Bad argument: {e}\n```{e.errors[-1]}```") + elif isinstance(e, errors.ArgumentParsingError): + await ctx.send(f"Argument parsing error: {e}") else: await ctx.send("Something about your input seems off. Check the arguments:") await ctx.invoke(*help_command) -- cgit v1.2.3 From 476d5e7851f5b53ece319f023eeca88ae5c345eb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 17:38:41 -0800 Subject: Error handler: (almost) always log the error being handled The log level is debug for most errors and it's mainly useful for precisely that - debugging. This is why some "useless" errors are also logged e.g. CommandNotFound. Unexpected errors and some API errors will still have higher levels. * Add a single log statement to the end of the handler to cover UserInputError, CheckFailure, and CommandNotFound (when it's not trying to get a tag) * Log 404s from API --- bot/cogs/error_handler.py | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 07b93283d..ff8b36ddc 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -50,23 +50,26 @@ class ErrorHandler(Cog): if isinstance(e, errors.CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"): if ctx.channel.id != Channels.verification: 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, errors.DisabledCommand)): - log.debug( - f"Command {command} invoked by {ctx.message.author} with error " - f"{e.__class__.__name__}: {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) - else: - # MaxConcurrencyReached, ExtensionError + return # Exit early to avoid logging. + elif not isinstance(e, (errors.CommandOnCooldown, 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}" + ) async def get_help_command(self, command: t.Optional[Command]) -> t.Tuple: """Return the help command invocation args to display help for `command`.""" @@ -129,10 +132,6 @@ class ErrorHandler(Cog): else: await ctx.send("Something about your input seems off. Check the arguments:") await ctx.invoke(*help_command) - log.debug( - f"Command {ctx.command} invoked by {ctx.message.author} with error " - f"{e.__class__.__name__}: {e}" - ) @staticmethod async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None: @@ -153,17 +152,13 @@ class ErrorHandler(Cog): ) elif isinstance(e, InChannelCheckFailure): await ctx.send(e) - else: - log.debug( - f"Command {command} invoked by {ctx.message.author} with error " - f"{e.__class__.__name__}: {e}" - ) @staticmethod async def handle_api_error(ctx: Context, e: ResponseCodeError) -> None: """Handle ResponseCodeError exceptions.""" 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}") elif e.status == 400: content = await e.response.json() log.debug(f"API responded with 400 for command {ctx.command}: %r.", content) -- cgit v1.2.3 From 9bfdf7e3e95c07a1b0369c9fa8bdb4c91339732f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 18:09:08 -0800 Subject: Error handler: simplify check failure handler & handle bot missing roles discord.py's default error messages are quite descriptive already so there really isn't a need to write our own. Therefore, the log calls were removed so that the generic debug log message is used in the on_command_error. In addition to handling missing bot permissions, missing bot roles are also handled. The message doesn't specify which because it doesn't really matter to the end-user. The logs will use the default error messages as described above, and those will contain the specific roles or permissions that are missing. --- bot/cogs/error_handler.py | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index ff8b36ddc..6c4074e3a 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -136,21 +136,17 @@ class ErrorHandler(Cog): @staticmethod async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None: """Handle CheckFailure exceptions and its children.""" - command = ctx.command + bot_missing_errors = ( + errors.BotMissingPermissions, + errors.BotMissingRole, + errors.BotMissingAnyRole + ) - if isinstance(e, errors.NoPrivateMessage): - await ctx.send("Sorry, this command can't be used in a private message!") - elif isinstance(e, errors.BotMissingPermissions): - await ctx.send(f"Sorry, it looks like I don't have the permissions I need to do that.") - log.warning( - f"The bot is missing permissions to execute command {command}: {e.missing_perms}" - ) - elif isinstance(e, errors.MissingPermissions): - log.debug( - f"{ctx.message.author} is missing permissions to invoke command {command}: " - f"{e.missing_perms}" + if isinstance(e, bot_missing_errors): + await ctx.send( + f"Sorry, it looks like I don't have the permissions or roles I need to do that." ) - elif isinstance(e, InChannelCheckFailure): + elif isinstance(e, (InChannelCheckFailure, errors.NoPrivateMessage)): await ctx.send(e) @staticmethod -- cgit v1.2.3 From aa84854f942d68f5245d2ca99612dfdd6ad167ce Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 19:01:42 -0800 Subject: Error handler: update docstrings to reflect recent changes --- bot/cogs/error_handler.py | 59 +++++++++++++++++++++++++++++++---------------- 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 6c4074e3a..d2c806566 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -23,22 +23,22 @@ class ErrorHandler(Cog): """ Provide generic command error handling. - Error handling is deferred to any local error handler, if present. - - Error handling emits a single error response, prioritized as follows: - 1. If the name fails to match a command but matches a tag, the tag is invoked - 2. Send a BadArgument error message to the invoking context & invoke the command's help - 3. Send a UserInputError error message to the invoking context & invoke the command's help - 4. Send a NoPrivateMessage error message to the invoking context - 5. Send a BotMissingPermissions error message to the invoking context - 6. Log a MissingPermissions error, no message is sent - 7. Send a InChannelCheckFailure error message to the invoking context - 8. Log CheckFailure, CommandOnCooldown, and DisabledCommand errors, no message is sent - 9. For CommandInvokeErrors, response is based on the type of error: - * 404: Error message is sent to the invoking context - * 400: Log the resopnse JSON, no message is sent - * 500 <= status <= 600: Error message is sent to the invoking context - 10. Otherwise, handling is deferred to `handle_unexpected_error` + 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 but 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. ResponseCodeError: see `handle_api_error` + 5. Otherwise, if not a CommandOnCooldown or DisabledCommand, handling is deferred to + `handle_unexpected_error` """ command = ctx.command @@ -112,7 +112,16 @@ class ErrorHandler(Cog): return async def handle_user_input_error(self, ctx: Context, e: errors.UserInputError) -> None: - """Handle UserInputError exceptions and its children.""" + """ + 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 + """ # TODO: use ctx.send_help() once PR #519 is merged. help_command = await self.get_help_command(ctx.command) @@ -135,7 +144,17 @@ class ErrorHandler(Cog): @staticmethod async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None: - """Handle CheckFailure exceptions and its children.""" + """ + Send an error message in `ctx` for certain types of CheckFailure. + + The following types are handled: + + * BotMissingPermissions + * BotMissingRole + * BotMissingAnyRole + * NoPrivateMessage + * InChannelCheckFailure + """ bot_missing_errors = ( errors.BotMissingPermissions, errors.BotMissingRole, @@ -151,7 +170,7 @@ class ErrorHandler(Cog): @staticmethod async def handle_api_error(ctx: Context, e: ResponseCodeError) -> None: - """Handle ResponseCodeError exceptions.""" + """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}") @@ -168,7 +187,7 @@ class ErrorHandler(Cog): @staticmethod async def handle_unexpected_error(ctx: Context, e: errors.CommandError) -> None: - """Generic handler for errors without an explicit handler.""" + """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}```" -- cgit v1.2.3 From b3fdfa71ceeecbd9fe62cadc240fcad27bad7a32 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 19:06:22 -0800 Subject: Error handler: handle CommandOnCooldown errors Simply send the error's default message to the invoking context. --- bot/cogs/error_handler.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index d2c806566..347ce93ae 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -36,9 +36,9 @@ class ErrorHandler(Cog): * Commands in the verification channel are ignored 2. UserInputError: see `handle_user_input_error` 3. CheckFailure: see `handle_check_failure` - 4. ResponseCodeError: see `handle_api_error` - 5. Otherwise, if not a CommandOnCooldown or DisabledCommand, handling is deferred to - `handle_unexpected_error` + 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 @@ -55,13 +55,15 @@ class ErrorHandler(Cog): 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.CommandOnCooldown, errors.DisabledCommand)): + elif not isinstance(e, errors.DisabledCommand): # ConversionError, MaxConcurrencyReached, ExtensionError await self.handle_unexpected_error(ctx, e) return # Exit early to avoid logging. -- cgit v1.2.3 From 68cf5d8eb9721cb1d91ba004409b82b0a283782d Mon Sep 17 00:00:00 2001 From: Matteo Date: Tue, 18 Feb 2020 17:48:53 +0100 Subject: Use pregenerated partials This avoid recreating partials for each re-eval --- bot/cogs/snekbox.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 3fc8d9937..d075c4fd5 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -203,6 +203,9 @@ class Snekbox(Cog): log.info(f"Received code from {ctx.author} for evaluation:\n{code}") + _predicate_eval_message_edit = partial(predicate_eval_message_edit, ctx) + _predicate_emoji_reaction = partial(predicate_eval_emoji_reaction, ctx) + while True: self.jobs[ctx.author.id] = datetime.datetime.now() code = self.prepare_input(code) @@ -234,13 +237,13 @@ class Snekbox(Cog): try: _, new_message = await self.bot.wait_for( 'message_edit', - check=partial(predicate_eval_message_edit, ctx), + check=_predicate_eval_message_edit, timeout=10 ) await ctx.message.add_reaction('🔁') await self.bot.wait_for( 'reaction_add', - check=partial(predicate_eval_emoji_reaction, ctx), + check=_predicate_emoji_reaction, timeout=10 ) -- cgit v1.2.3 From e1e68ad561513cc02eac0eab59990430fbcfe516 Mon Sep 17 00:00:00 2001 From: Matteo Date: Tue, 18 Feb 2020 18:00:09 +0100 Subject: Suppress HTTPException while deleting bot output It was triggering an error if the user deleted the output before re-evaluating --- bot/cogs/snekbox.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index d075c4fd5..42830fb58 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -1,4 +1,5 @@ import asyncio +import contextlib import datetime import logging import re @@ -7,7 +8,7 @@ from functools import partial from signal import Signals from typing import Optional, Tuple -from discord import Message, Reaction, User +from discord import HTTPException, Message, Reaction, User from discord.ext.commands import Cog, Context, command, guild_only from bot.bot import Bot @@ -250,7 +251,8 @@ class Snekbox(Cog): log.info(f"Re-evaluating message {ctx.message.id}") code = new_message.content.split(' ', maxsplit=1)[1] await ctx.message.clear_reactions() - await response.delete() + with contextlib.suppress(HTTPException): + await response.delete() except asyncio.TimeoutError: await ctx.message.clear_reactions() return -- cgit v1.2.3 From 8b386b533662e7f4be44cc57b9d9c63fde8a7ebf Mon Sep 17 00:00:00 2001 From: Matteo Date: Tue, 18 Feb 2020 18:14:31 +0100 Subject: Snekbox small refactoring Makes the code a bit clearer Co-authored-by: Shirayuki Nekomata --- bot/cogs/snekbox.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 42830fb58..efa4696b5 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -158,8 +158,8 @@ class Snekbox(Cog): lines = output.count("\n") if lines > 0: - output = output.split("\n")[:11] # Only first 11 cause the rest is truncated anyway - output = (f"{i:03d} | {line}" for i, line in enumerate(output, 1)) + 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: @@ -175,8 +175,7 @@ class Snekbox(Cog): if truncated: paste_link = await self.upload_output(original_output) - if not output: - output = "[No output]" + output = output or "[No output]" return output, paste_link -- cgit v1.2.3 From 33769405549efc3c0571cd1b2da5fa0b59ec742a Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 18 Feb 2020 18:23:38 +0100 Subject: Split assertion onto separate lines Co-Authored-By: Mark --- tests/bot/cogs/test_snekbox.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 293efed0f..b5190cd7f 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -35,7 +35,8 @@ class SnekboxTests(unittest.TestCase): @async_test async def test_upload_output_reject_too_long(self): """Reject output longer than MAX_PASTE_LEN.""" - self.assertEqual(await self.cog.upload_output("-" * (snekbox.MAX_PASTE_LEN + 1)), "too long to upload") + result = await self.cog.upload_output("-" * (snekbox.MAX_PASTE_LEN + 1)) + self.assertEqual(result, "too long to upload") @async_test async def test_upload_output(self): -- cgit v1.2.3 From 2974d489494370f364c44899a529f5e93c89bedc Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 18 Feb 2020 18:25:55 +0100 Subject: Split assertions onto separate lines Reads better as separate lines Co-Authored-By: Mark --- tests/bot/cogs/test_snekbox.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index b5190cd7f..01525110d 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -88,7 +88,8 @@ class SnekboxTests(unittest.TestCase): ) for stdout, returncode, expected in cases: with self.subTest(stdout=stdout, returncode=returncode, expected=expected): - self.assertEqual(self.cog.get_results_message({'stdout': stdout, 'returncode': returncode}), 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): @@ -114,7 +115,8 @@ class SnekboxTests(unittest.TestCase): ) for stdout, returncode, expected in cases: with self.subTest(stdout=stdout, returncode=returncode, expected=expected): - self.assertEqual(self.cog.get_status_emoji({'stdout': stdout, 'returncode': returncode}), expected) + actual = self.cog.get_status_emoji({'stdout': stdout, 'returncode': returncode}) + self.assertEqual(actual, expected) @async_test async def test_format_output(self): @@ -321,7 +323,8 @@ class SnekboxTests(unittest.TestCase): with self.subTest(msg=f'Messages with {testname} return {expected}'): ctx = MockContext() ctx.message = ctx_msg - self.assertEqual(snekbox.predicate_eval_message_edit(ctx, ctx_msg, new_msg), expected) + actual = snekbox.predicate_eval_message_edit(ctx, ctx_msg, new_msg) + self.assertEqual(actual, expected) def test_predicate_eval_emoji_reaction(self): """Test the predicate_eval_emoji_reaction function.""" @@ -351,7 +354,8 @@ class SnekboxTests(unittest.TestCase): ) for reaction, user, expected, testname in cases: with self.subTest(msg=f'Test with {testname} and expected return {expected}'): - self.assertEqual(snekbox.predicate_eval_emoji_reaction(valid_ctx, reaction, user), expected) + actual = snekbox.predicate_eval_emoji_reaction(valid_ctx, reaction, user) + self.assertEqual(actual, expected) class SnekboxSetupTests(unittest.TestCase): -- cgit v1.2.3 From dd9c250253a7bb24751f2de274dfa168efafb717 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 18 Feb 2020 18:29:22 +0100 Subject: Delete additional informations from subtest Reduce visual clutter Co-Authored-By: Mark --- 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 01525110d..f1f03ab2f 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -76,7 +76,7 @@ class SnekboxTests(unittest.TestCase): ('```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}.', case=case, expected=expected): + with self.subTest(msg=f'Extract code from {testname}.'): self.assertEqual(self.cog.prepare_input(case), expected) def test_get_results_message(self): -- cgit v1.2.3 From b5a1bf7c6ca467cef40a429cc8ca02314526801c Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 18 Feb 2020 18:30:39 +0100 Subject: Use a space instead of an empty string in test_get_status_emoji Because of the stripping, it should still be considered as empty Co-Authored-By: Mark --- 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 f1f03ab2f..94ff685c4 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -109,7 +109,7 @@ class SnekboxTests(unittest.TestCase): def test_get_status_emoji(self): """Return emoji according to the eval result.""" cases = ( - ('', -1, ':warning:'), + (' ', -1, ':warning:'), ('Hello world!', 0, ':white_check_mark:'), ('Invalid beard size', -1, ':x:') ) -- cgit v1.2.3 From c1a998ca246e153333438ff91bce200bf74cc0f5 Mon Sep 17 00:00:00 2001 From: Matteo Date: Tue, 18 Feb 2020 18:38:24 +0100 Subject: Assert return value of Snekbox.post_eval --- tests/bot/cogs/test_snekbox.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 94ff685c4..cf07aad71 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -25,7 +25,9 @@ class SnekboxTests(unittest.TestCase): @async_test async def test_post_eval(self): """Post the eval code to the URLs.snekbox_eval_api endpoint.""" - await self.cog.post_eval("import random") + self.mocked_post.json.return_value = {'lemon': 'AI'} + + self.assertEqual(await self.cog.post_eval("import random"), {'lemon': 'AI'}) self.bot.http_session.post.assert_called_once_with( URLs.snekbox_eval_api, json={"input": "import random"}, -- cgit v1.2.3 From 12f43fc09406dee8cb1b36757fc2af7ae799a9d5 Mon Sep 17 00:00:00 2001 From: Matteo Date: Tue, 18 Feb 2020 18:44:00 +0100 Subject: Use kwargs to set mock attributes --- tests/bot/cogs/test_snekbox.py | 36 +++++++++++------------------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index cf07aad71..112c923c8 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -306,15 +306,9 @@ class SnekboxTests(unittest.TestCase): def test_predicate_eval_message_edit(self): """Test the predicate_eval_message_edit function.""" - msg0 = MockMessage() - msg0.id = 1 - msg0.content = 'abc' - msg1 = MockMessage() - msg1.id = 2 - msg1.content = 'abcdef' - msg2 = MockMessage() - msg2.id = 1 - msg2.content = 'abcdef' + msg0 = MockMessage(id=1, content='abc') + msg1 = MockMessage(id=2, content='abcdef') + msg2 = MockMessage(id=1, content='abcdef') cases = ( (msg0, msg0, False, 'same ID, same content'), @@ -323,29 +317,21 @@ class SnekboxTests(unittest.TestCase): ) for ctx_msg, new_msg, expected, testname in cases: with self.subTest(msg=f'Messages with {testname} return {expected}'): - ctx = MockContext() - ctx.message = ctx_msg + ctx = MockContext(message=ctx_msg) actual = snekbox.predicate_eval_message_edit(ctx, ctx_msg, new_msg) self.assertEqual(actual, expected) def test_predicate_eval_emoji_reaction(self): """Test the predicate_eval_emoji_reaction function.""" - valid_reaction = MockReaction() - valid_reaction.message.id = 1 + valid_reaction = MockReaction(message=MockMessage(id=1)) valid_reaction.__str__.return_value = '🔁' - valid_ctx = MockContext() - valid_ctx.message.id = 1 - valid_ctx.author.id = 2 - valid_user = MockUser() - valid_user.id = 2 - - invalid_reaction_id = MockReaction() - invalid_reaction_id.message.id = 42 + valid_ctx = MockContext(message=MockMessage(id=1), author=MockUser(id=2)) + valid_user = MockUser(id=2) + + invalid_reaction_id = MockReaction(message=MockMessage(id=42)) invalid_reaction_id.__str__.return_value = '🔁' - invalid_user_id = MockUser() - invalid_user_id.id = 42 - invalid_reaction_str = MockReaction() - invalid_reaction_str.message.id = 1 + invalid_user_id = MockUser(id=42) + invalid_reaction_str = MockReaction(message=MockMessage(id=1)) invalid_reaction_str.__str__.return_value = ':longbeard:' cases = ( -- cgit v1.2.3 From e5a7af3811f7f2687026254f44194b3a16459ca2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 19 Feb 2020 09:26:42 -0800 Subject: Sync: add confirmation timeout and max diff to config --- bot/cogs/sync/syncers.py | 25 +++++++++++-------------- bot/constants.py | 7 +++++++ config-default.yml | 4 ++++ tests/bot/cogs/sync/test_base.py | 4 ++-- 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 23039d1fc..43a8f2b62 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -26,9 +26,6 @@ class Syncer(abc.ABC): _CORE_DEV_MENTION = f"<@&{constants.Roles.core_developer}> " _REACTION_EMOJIS = (constants.Emojis.check_mark, constants.Emojis.cross_mark) - CONFIRM_TIMEOUT = 60 * 5 # 5 minutes - MAX_DIFF = 10 - def __init__(self, bot: Bot) -> None: self.bot = bot @@ -50,7 +47,7 @@ class Syncer(abc.ABC): msg_content = ( f'Possible cache issue while syncing {self.name}s. ' - f'More than {self.MAX_DIFF} {self.name}s were changed. ' + f'More than {constants.Sync.max_diff} {self.name}s were changed. ' f'React to confirm or abort the sync.' ) @@ -110,8 +107,8 @@ class Syncer(abc.ABC): Uses the `_reaction_check` function to determine if a reaction is valid. - If there is no reaction within `CONFIRM_TIMEOUT` seconds, return False. To acknowledge the - reaction (or lack thereof), `message` will be edited. + 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. @@ -123,7 +120,7 @@ class Syncer(abc.ABC): reaction, _ = await self.bot.wait_for( 'reaction_add', check=partial(self._reaction_check, author, message), - timeout=self.CONFIRM_TIMEOUT + timeout=constants.Sync.confirm_timeout ) except TimeoutError: # reaction will remain none thus sync will be aborted in the finally block below. @@ -159,15 +156,15 @@ class Syncer(abc.ABC): """ 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 `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. + `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 > self.MAX_DIFF: + if diff_size > constants.Sync.max_diff: message = await self._send_prompt(message) if not message: return False, None # Couldn't get channel. @@ -182,9 +179,9 @@ class Syncer(abc.ABC): """ Synchronise the database with the cache of `guild`. - If the differences between the cache and the database are greater than `MAX_DIFF`, then - a confirmation prompt will be sent to the dev-core channel. The confirmation can be - optionally redirect to `ctx` instead. + 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.") diff --git a/bot/constants.py b/bot/constants.py index 6279388de..81ce3e903 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -539,6 +539,13 @@ class RedirectOutput(metaclass=YAMLGetter): delete_delay: int +class Sync(metaclass=YAMLGetter): + section = 'sync' + + confirm_timeout: int + max_diff: int + + 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 74dcc1862..0ebdc4080 100644 --- a/config-default.yml +++ b/config-default.yml @@ -430,6 +430,10 @@ redirect_output: delete_invocation: true delete_delay: 15 +sync: + confirm_timeout: 300 + max_diff: 10 + 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] diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 0539f5683..e6a6f9688 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -361,10 +361,10 @@ class SyncerSyncTests(unittest.TestCase): 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) @helpers.async_test async def test_confirmation_result_small_diff(self): """Should always return True and the given message if the diff size is too small.""" - self.syncer.MAX_DIFF = 3 author = helpers.MockMember() expected_message = helpers.MockMessage() @@ -381,10 +381,10 @@ class SyncerSyncTests(unittest.TestCase): self.syncer._send_prompt.assert_not_called() self.syncer._wait_for_confirmation.assert_not_called() + @mock.patch.object(constants.Sync, "max_diff", new=3) @helpers.async_test async def test_confirmation_result_large_diff(self): """Should return True if confirmed and False if _send_prompt fails or aborted.""" - self.syncer.MAX_DIFF = 3 author = helpers.MockMember() mock_message = helpers.MockMessage() -- cgit v1.2.3 From 51ce6225e1dce6e909101d5948264615a1e068ea Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 19 Feb 2020 10:06:15 -0800 Subject: API: add comment explaining class attributes Explain changes caused by 22a55534ef13990815a6f69d361e2a12693075d5. --- bot/api.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/api.py b/bot/api.py index d5880ba18..1562c7fce 100644 --- a/bot/api.py +++ b/bot/api.py @@ -32,6 +32,8 @@ class ResponseCodeError(ValueError): class APIClient: """Django Site API wrapper.""" + # These are class attributes so they can be seen when being mocked for tests. + # See commit 22a55534ef13990815a6f69d361e2a12693075d5 for details. session: Optional[aiohttp.ClientSession] = None loop: asyncio.AbstractEventLoop = None -- cgit v1.2.3 From e3fab4567031cb0a3087b3335cd30f87e0027301 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 21 Feb 2020 12:11:12 -0800 Subject: Bot: send empty cache warning to a webhook This is more visible than it would be if it was only logged. * Add a webhook for the dev-log channel to constants --- bot/bot.py | 13 ++++++++++--- bot/constants.py | 1 + config-default.yml | 1 + 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index e5b9717db..c818e79fb 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -68,9 +68,16 @@ class Bot(commands.Bot): return if not guild.roles or not guild.members or not guild.channels: - log.warning( - "Guild available event was dispatched but the cache appears to still be empty!" - ) + msg = "Guild available event was dispatched but the cache appears to still be empty!" + log.warning(msg) + + try: + webhook = await self.fetch_webhook(constants.Webhooks.dev_log) + except discord.HTTPException as e: + log.error(f"Failed to fetch webhook to send empty cache warning: status {e.status}") + else: + await webhook.send(f"<@&{constants.Roles.admin}> {msg}") + return self._guild_available.set() diff --git a/bot/constants.py b/bot/constants.py index 81ce3e903..9856854d7 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -406,6 +406,7 @@ class Webhooks(metaclass=YAMLGetter): big_brother: int reddit: int duck_pond: int + dev_log: int class Roles(metaclass=YAMLGetter): diff --git a/config-default.yml b/config-default.yml index 0ebdc4080..6808925c2 100644 --- a/config-default.yml +++ b/config-default.yml @@ -179,6 +179,7 @@ guild: big_brother: 569133704568373283 reddit: 635408384794951680 duck_pond: 637821475327311927 + dev_log: 680501655111729222 filter: -- cgit v1.2.3 From f91c32fed74bd5daebb8438c79f4d2d9efbc1459 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 09:59:28 -0800 Subject: Reminders: don't cancel task if reminder is invalid when rescheduling If a reminder is invalid, it won't get rescheduled. Therefore, there wouldn't exist a task to cancel and it'd raise a warning. Fixes BOT-1C --- bot/cogs/reminders.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index ef46f4f3e..f3e516158 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -45,7 +45,7 @@ class Reminders(Scheduler, Cog): loop = asyncio.get_event_loop() for reminder in response: - is_valid, *_ = self.ensure_valid_reminder(reminder) + is_valid, *_ = self.ensure_valid_reminder(reminder, cancel_task=False) if not is_valid: continue @@ -59,7 +59,11 @@ class Reminders(Scheduler, Cog): else: self.schedule_task(loop, reminder["id"], reminder) - def ensure_valid_reminder(self, reminder: dict) -> t.Tuple[bool, discord.User, discord.TextChannel]: + 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']) @@ -70,7 +74,7 @@ class Reminders(Scheduler, Cog): f"Reminder {reminder['id']} invalid: " f"User {reminder['author']}={user}, Channel {reminder['channel_id']}={channel}." ) - asyncio.create_task(self._delete_reminder(reminder['id'])) + asyncio.create_task(self._delete_reminder(reminder['id'], cancel_task)) return is_valid, user, channel @@ -98,12 +102,13 @@ class Reminders(Scheduler, Cog): # Now we can begone with it from our schedule list. self.cancel_task(reminder_id) - async def _delete_reminder(self, reminder_id: str) -> None: + 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)) - # Now we can remove it from the schedule list - self.cancel_task(reminder_id) + if cancel_task: + # Now we can remove it from the schedule list + self.cancel_task(reminder_id) async def _reschedule_reminder(self, reminder: dict) -> None: """Reschedule a reminder object.""" -- cgit v1.2.3 From f12d76dca1073b489b4a407a17dbab623aa0dce5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 11:18:01 -0800 Subject: Config: rename roles to match their names in the guild --- config-default.yml | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/config-default.yml b/config-default.yml index 379475907..5755b2191 100644 --- a/config-default.yml +++ b/config-default.yml @@ -160,20 +160,20 @@ guild: reminder_whitelist: [*BOT_CMD, *DEV_CONTRIB] roles: - admin: &ADMIN_ROLE 267628507062992896 - announcements: 463658397560995840 - champion: 430492892331769857 - contributor: 295488872404484098 - core_developer: 587606783669829632 - helpers: 267630620367257601 - jammer: 591786436651646989 - moderator: &MOD_ROLE 267629731250176001 - muted: &MUTED_ROLE 277914926603829249 - owner: &OWNER_ROLE 267627879762755584 - partners: 323426753857191936 - rockstars: &ROCKSTARS_ROLE 458226413825294336 - team_leader: 501324292341104650 - verified: 352427296948486144 + admins: &ADMINS_ROLE 267628507062992896 + announcements: 463658397560995840 + code_jam_champions: 430492892331769857 + contributors: 295488872404484098 + core_developers: 587606783669829632 + developers: 352427296948486144 + helpers: 267630620367257601 + jammers: 591786436651646989 + moderators: &MODS_ROLE 267629731250176001 + muted: &MUTED_ROLE 277914926603829249 + owners: &OWNERS_ROLE 267627879762755584 + partners: 323426753857191936 + python_community: &PYTHON_COMMUNITY_ROLE 458226413825294336 + team_leaders: 501324292341104650 webhooks: talent_pool: 569145364800602132 @@ -267,10 +267,10 @@ filter: - *USER_EVENT_A role_whitelist: - - *ADMIN_ROLE - - *MOD_ROLE - - *OWNER_ROLE - - *ROCKSTARS_ROLE + - *ADMINS_ROLE + - *MODS_ROLE + - *OWNERS_ROLE + - *PYTHON_COMMUNITY_ROLE keys: -- cgit v1.2.3 From ffdde0f8a2f14590baf47462143fbd685d851fad Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 11:20:54 -0800 Subject: Config: split roles into categories --- config-default.yml | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/config-default.yml b/config-default.yml index 5755b2191..203f9e64e 100644 --- a/config-default.yml +++ b/config-default.yml @@ -160,26 +160,30 @@ guild: reminder_whitelist: [*BOT_CMD, *DEV_CONTRIB] roles: - admins: &ADMINS_ROLE 267628507062992896 announcements: 463658397560995840 - code_jam_champions: 430492892331769857 contributors: 295488872404484098 - core_developers: 587606783669829632 developers: 352427296948486144 - helpers: 267630620367257601 - jammers: 591786436651646989 - moderators: &MODS_ROLE 267629731250176001 muted: &MUTED_ROLE 277914926603829249 - owners: &OWNERS_ROLE 267627879762755584 partners: 323426753857191936 python_community: &PYTHON_COMMUNITY_ROLE 458226413825294336 - team_leaders: 501324292341104650 + + # Staff + admins: &ADMINS_ROLE 267628507062992896 + core_developers: 587606783669829632 + helpers: 267630620367257601 + moderators: &MODS_ROLE 267629731250176001 + owners: &OWNERS_ROLE 267627879762755584 + + # Code Jam + code_jam_champions: 430492892331769857 + jammers: 591786436651646989 + team_leaders: 501324292341104650 webhooks: - talent_pool: 569145364800602132 - big_brother: 569133704568373283 - reddit: 635408384794951680 - duck_pond: 637821475327311927 + talent_pool: 569145364800602132 + big_brother: 569133704568373283 + reddit: 635408384794951680 + duck_pond: 637821475327311927 filter: -- cgit v1.2.3 From bf7dd8c17dea3d0c12139893c210500fdad820f9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 11:46:12 -0800 Subject: Config: split channels into categories --- config-default.yml | 60 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/config-default.yml b/config-default.yml index 203f9e64e..415ed13f4 100644 --- a/config-default.yml +++ b/config-default.yml @@ -113,19 +113,32 @@ guild: python_help: 356013061213126657 channels: - admins: &ADMINS 365960823622991872 - admin_spam: &ADMIN_SPAM 563594791770914816 - admins_voice: &ADMINS_VOICE 500734494840717332 announcements: 354619224620138496 - attachment_log: &ATTCH_LOG 649243850006855680 - big_brother_logs: &BBLOGS 468507907357409333 - bot: &BOT_CMD 267659945086812160 checkpoint_test: 422077681434099723 - defcon: &DEFCON 464469101889454091 + user_event_a: &USER_EVENT_A 592000283102674944 + + # Development devcontrib: &DEV_CONTRIB 635950537262759947 devlog: &DEVLOG 622895325144940554 devtest: &DEVTEST 414574275865870337 - esoteric: 470884583684964352 + + # Discussion + meta: 429409067623251969 + python: 267624335836053506 + + # Logs + attachment_log: &ATTCH_LOG 649243850006855680 + message_log: &MESSAGE_LOG 467752170159079424 + modlog: &MODLOG 282638479504965634 + userlog: 528976905546760203 + voice_log: 640292421988646961 + + # Off-topic + off_topic_0: 291284109232308226 + off_topic_1: 463035241142026251 + off_topic_2: 463035268514185226 + + # Python Help help_0: 303906576991780866 help_1: 303906556754395136 help_2: 303906514266226689 @@ -134,26 +147,31 @@ guild: help_5: 454941769734422538 help_6: 587375753306570782 help_7: 587375768556797982 + + # Special + bot: &BOT_CMD 267659945086812160 + esoteric: 470884583684964352 + reddit: 458224812528238616 + verification: 352442727016693763 + + # Staff + admins: &ADMINS 365960823622991872 + admin_spam: &ADMIN_SPAM 563594791770914816 + defcon: &DEFCON 464469101889454091 helpers: &HELPERS 385474242440986624 - message_log: &MESSAGE_LOG 467752170159079424 - meta: 429409067623251969 - mod_spam: &MOD_SPAM 620607373828030464 mods: &MODS 305126844661760000 mod_alerts: 473092532147060736 - modlog: &MODLOG 282638479504965634 - off_topic_0: 291284109232308226 - off_topic_1: 463035241142026251 - off_topic_2: 463035268514185226 + mod_spam: &MOD_SPAM 620607373828030464 organisation: &ORGANISATION 551789653284356126 - python: 267624335836053506 - reddit: 458224812528238616 staff_lounge: &STAFF_LOUNGE 464905259261755392 + + # Voice + admins_voice: &ADMINS_VOICE 500734494840717332 staff_voice: &STAFF_VOICE 412375055910043655 + + # Watch + big_brother_logs: &BBLOGS 468507907357409333 talent_pool: &TALENT_POOL 534321732593647616 - userlog: 528976905546760203 - user_event_a: &USER_EVENT_A 592000283102674944 - verification: 352442727016693763 - voice_log: 640292421988646961 staff_channels: [*ADMINS, *ADMIN_SPAM, *MOD_SPAM, *MODS, *HELPERS, *ORGANISATION, *DEFCON] ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG, *ADMINS_VOICE, *STAFF_VOICE, *ATTCH_LOG] -- cgit v1.2.3 From 707d2f7208e7008f94bfff1a1e13664dc38c6d6c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 11:47:11 -0800 Subject: Config: shorten name of PYTHON_COMMUNITY_ROLE --- config-default.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/config-default.yml b/config-default.yml index 415ed13f4..1e478154f 100644 --- a/config-default.yml +++ b/config-default.yml @@ -178,12 +178,12 @@ guild: reminder_whitelist: [*BOT_CMD, *DEV_CONTRIB] roles: - announcements: 463658397560995840 - contributors: 295488872404484098 - developers: 352427296948486144 - muted: &MUTED_ROLE 277914926603829249 - partners: 323426753857191936 - python_community: &PYTHON_COMMUNITY_ROLE 458226413825294336 + announcements: 463658397560995840 + contributors: 295488872404484098 + developers: 352427296948486144 + muted: &MUTED_ROLE 277914926603829249 + partners: 323426753857191936 + python_community: &PY_COMMUNITY_ROLE 458226413825294336 # Staff admins: &ADMINS_ROLE 267628507062992896 @@ -292,7 +292,7 @@ filter: - *ADMINS_ROLE - *MODS_ROLE - *OWNERS_ROLE - - *PYTHON_COMMUNITY_ROLE + - *PY_COMMUNITY_ROLE keys: -- cgit v1.2.3 From 8c1a31b7d043facd876b22800e8cb44ae15d9492 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 11:50:21 -0800 Subject: Config: remove checkpoint_test and devtest They no longer exist in the guild. * Move devlog under the "Logs" category --- bot/cogs/bot.py | 1 - bot/cogs/tags.py | 1 - bot/constants.py | 2 -- config-default.yml | 2 -- 4 files changed, 6 deletions(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index 73b1e8f41..74e882e0e 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -40,7 +40,6 @@ class BotCog(Cog, name="Bot"): # These channels will also work, but will not be subject to cooldown self.channel_whitelist = ( Channels.bot, - Channels.devtest, ) # Stores improperly formatted Python codeblock message ids and the corresponding bot message diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index b6360dfae..a38f5617f 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -15,7 +15,6 @@ from bot.pagination import LinePaginator log = logging.getLogger(__name__) TEST_CHANNELS = ( - Channels.devtest, Channels.bot, Channels.helpers ) diff --git a/bot/constants.py b/bot/constants.py index 681d8da49..15f078cbf 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -363,11 +363,9 @@ class Channels(metaclass=YAMLGetter): attachment_log: int big_brother_logs: int bot: int - checkpoint_test: int defcon: int devcontrib: int devlog: int - devtest: int esoteric: int help_0: int help_1: int diff --git a/config-default.yml b/config-default.yml index 1e478154f..058317262 100644 --- a/config-default.yml +++ b/config-default.yml @@ -114,13 +114,11 @@ guild: channels: announcements: 354619224620138496 - checkpoint_test: 422077681434099723 user_event_a: &USER_EVENT_A 592000283102674944 # Development devcontrib: &DEV_CONTRIB 635950537262759947 devlog: &DEVLOG 622895325144940554 - devtest: &DEVTEST 414574275865870337 # Discussion meta: 429409067623251969 -- cgit v1.2.3 From a850a32135ef3b454abfe2ad017a46badd73d3c1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 11:54:58 -0800 Subject: Constants: rename roles to match their names in the guild --- bot/cogs/defcon.py | 12 ++++++------ bot/cogs/eval.py | 4 ++-- bot/cogs/extensions.py | 2 +- bot/cogs/jams.py | 6 +++--- bot/cogs/moderation/scheduler.py | 2 +- bot/cogs/snekbox.py | 2 +- bot/cogs/tags.py | 2 +- bot/cogs/verification.py | 2 +- bot/constants.py | 22 +++++++++++----------- tests/bot/cogs/test_information.py | 2 +- 10 files changed, 28 insertions(+), 28 deletions(-) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index a0d8fedd5..c7ea1f2bf 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -69,7 +69,7 @@ class Defcon(Cog): except Exception: # Yikes! log.exception("Unable to get DEFCON settings!") await self.bot.get_channel(Channels.devlog).send( - f"<@&{Roles.admin}> **WARNING**: Unable to get DEFCON settings!" + f"<@&{Roles.admins}> **WARNING**: Unable to get DEFCON settings!" ) else: @@ -118,7 +118,7 @@ class Defcon(Cog): ) @group(name='defcon', aliases=('dc',), invoke_without_command=True) - @with_role(Roles.admin, Roles.owner) + @with_role(Roles.admins, Roles.owners) async def defcon_group(self, ctx: Context) -> None: """Check the DEFCON status or run a subcommand.""" await ctx.invoke(self.bot.get_command("help"), "defcon") @@ -146,7 +146,7 @@ class Defcon(Cog): await self.send_defcon_log(action, ctx.author, error) @defcon_group.command(name='enable', aliases=('on', 'e')) - @with_role(Roles.admin, Roles.owner) + @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! @@ -159,7 +159,7 @@ class Defcon(Cog): await self.update_channel_topic() @defcon_group.command(name='disable', aliases=('off', 'd')) - @with_role(Roles.admin, Roles.owner) + @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 @@ -167,7 +167,7 @@ class Defcon(Cog): await self.update_channel_topic() @defcon_group.command(name='status', aliases=('s',)) - @with_role(Roles.admin, Roles.owner) + @with_role(Roles.admins, Roles.owners) async def status_command(self, ctx: Context) -> None: """Check the current status of DEFCON mode.""" embed = Embed( @@ -179,7 +179,7 @@ class Defcon(Cog): await ctx.send(embed=embed) @defcon_group.command(name='days') - @with_role(Roles.admin, Roles.owner) + @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) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 9c729f28a..52136fc8d 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -174,14 +174,14 @@ async def func(): # (None,) -> Any await ctx.send(f"```py\n{out}```", embed=embed) @group(name='internal', aliases=('int',)) - @with_role(Roles.owner, Roles.admin) + @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.invoke(self.bot.get_command("help"), "internal") @internal_group.command(name='eval', aliases=('e',)) - @with_role(Roles.admin, Roles.owner) + @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("`") diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index f16e79fb7..b312e1a1d 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -221,7 +221,7 @@ class Extensions(commands.Cog): # 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_developer) + 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: diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py index 985f28ce5..1d062b0c2 100644 --- a/bot/cogs/jams.py +++ b/bot/cogs/jams.py @@ -18,7 +18,7 @@ class CodeJams(commands.Cog): self.bot = bot @commands.command() - @with_role(Roles.admin) + @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. @@ -95,10 +95,10 @@ class CodeJams(commands.Cog): ) # Assign team leader role - await members[0].add_roles(ctx.guild.get_role(Roles.team_leader)) + await members[0].add_roles(ctx.guild.get_role(Roles.team_leaders)) # Assign rest of roles - jammer_role = ctx.guild.get_role(Roles.jammer) + jammer_role = ctx.guild.get_role(Roles.jammers) for member in members: await member.add_roles(jammer_role) diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index c0de0e4da..db1a3030e 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -307,7 +307,7 @@ class InfractionScheduler(Scheduler): Infractions of unsupported types will raise a ValueError. """ guild = self.bot.get_guild(constants.Guild.id) - mod_role = guild.get_role(constants.Roles.moderator) + mod_role = guild.get_role(constants.Roles.moderators) user_id = infraction["user"] actor = infraction["actor"] type_ = infraction["type"] diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index da33e27b2..84457e38f 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -34,7 +34,7 @@ RAW_CODE_REGEX = re.compile( ) MAX_PASTE_LEN = 1000 -EVAL_ROLES = (Roles.helpers, Roles.moderator, Roles.admin, Roles.owner, Roles.rockstars, Roles.partners) +EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners) class Snekbox(Cog): diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index a38f5617f..2c4fa02bd 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -220,7 +220,7 @@ class Tags(Cog): )) @tags_group.command(name='delete', aliases=('remove', 'rm', 'd')) - @with_role(Roles.admin, Roles.owner) + @with_role(Roles.admins, Roles.owners) async def delete_command(self, ctx: Context, *, tag_name: TagNameConverter) -> None: """Remove a tag from the database.""" await self.bot.api_client.delete(f'bot/tags/{tag_name}') diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 582237374..09bef80c4 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -38,7 +38,7 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! PERIODIC_PING = ( f"@everyone To verify that you have read our rules, please type `{BotConfig.prefix}accept`." - f" If you encounter any problems during the verification process, ping the <@&{Roles.admin}> role in this channel." + f" If you encounter any problems during the verification process, ping the <@&{Roles.admins}> role in this channel." ) BOT_MESSAGE_DELETE_DELAY = 10 diff --git a/bot/constants.py b/bot/constants.py index 15f078cbf..03578fefd 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -409,19 +409,19 @@ class Roles(metaclass=YAMLGetter): section = "guild" subsection = "roles" - admin: int + admins: int announcements: int - champion: int - contributor: int - core_developer: int + code_jam_champions: int + contributors: int + core_developers: int helpers: int - jammer: int - moderator: int + jammers: int + moderators: int muted: int - owner: int + owners: int partners: int - rockstars: int - team_leader: int + python_community: int + team_leaders: int verified: int # This is the Developers role on PyDis, here named verified for readability reasons. @@ -570,8 +570,8 @@ BOT_DIR = os.path.dirname(__file__) PROJECT_ROOT = os.path.abspath(os.path.join(BOT_DIR, os.pardir)) # Default role combinations -MODERATION_ROLES = Roles.moderator, Roles.admin, Roles.owner -STAFF_ROLES = Roles.helpers, Roles.moderator, Roles.admin, Roles.owner +MODERATION_ROLES = Roles.moderators, Roles.admins, Roles.owners +STAFF_ROLES = Roles.helpers, Roles.moderators, Roles.admins, Roles.owners # Roles combinations STAFF_CHANNELS = Guild.staff_channels diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index deae7ebad..38293269f 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -19,7 +19,7 @@ class InformationCogTests(unittest.TestCase): @classmethod def setUpClass(cls): - cls.moderator_role = helpers.MockRole(name="Moderator", id=constants.Roles.moderator) + cls.moderator_role = helpers.MockRole(name="Moderator", id=constants.Roles.moderators) def setUp(self): """Sets up fresh objects for each test.""" -- cgit v1.2.3 From ed25a7ae240cce1162a0a67fa6451363671cc7de Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 11:58:51 -0800 Subject: Constants: rename developers role back to verified It makes the code which uses it more readable. A comment was added to explain the discrepancy between the constant's name and the name in the guild. --- config-default.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index 058317262..dc5be5d47 100644 --- a/config-default.yml +++ b/config-default.yml @@ -178,11 +178,13 @@ guild: roles: announcements: 463658397560995840 contributors: 295488872404484098 - developers: 352427296948486144 muted: &MUTED_ROLE 277914926603829249 partners: 323426753857191936 python_community: &PY_COMMUNITY_ROLE 458226413825294336 + # This is the Developers role on PyDis, here named verified for readability reasons + verified: 352427296948486144 + # Staff admins: &ADMINS_ROLE 267628507062992896 core_developers: 587606783669829632 -- cgit v1.2.3 From e38990dc26fd77323e33da267ab8f16538f32438 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 11:59:38 -0800 Subject: Constants: remove code jam champions role Nothing was using it. --- bot/constants.py | 1 - config-default.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 03578fefd..98914fb9d 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -411,7 +411,6 @@ class Roles(metaclass=YAMLGetter): admins: int announcements: int - code_jam_champions: int contributors: int core_developers: int helpers: int diff --git a/config-default.yml b/config-default.yml index dc5be5d47..10cc34816 100644 --- a/config-default.yml +++ b/config-default.yml @@ -193,7 +193,6 @@ guild: owners: &OWNERS_ROLE 267627879762755584 # Code Jam - code_jam_champions: 430492892331769857 jammers: 591786436651646989 team_leaders: 501324292341104650 -- cgit v1.2.3 From 734fd8ff1a0e5bc309b0d84e34b2b6a1d0c204d9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 12:16:32 -0800 Subject: Config: rename channels to match their names in the guild --- bot/cogs/bot.py | 4 +- bot/cogs/clean.py | 2 +- bot/cogs/defcon.py | 2 +- bot/cogs/free.py | 2 +- bot/cogs/help.py | 2 +- bot/cogs/information.py | 6 +-- bot/cogs/logging.py | 2 +- bot/cogs/moderation/modlog.py | 12 +++--- bot/cogs/snekbox.py | 2 +- bot/cogs/tags.py | 2 +- bot/cogs/utils.py | 2 +- bot/cogs/verification.py | 9 +++-- bot/constants.py | 12 +++--- config-default.yml | 93 +++++++++++++++++++++---------------------- 14 files changed, 76 insertions(+), 76 deletions(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index 74e882e0e..f17135877 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -34,12 +34,12 @@ class BotCog(Cog, name="Bot"): Channels.help_5: 0, Channels.help_6: 0, Channels.help_7: 0, - Channels.python: 0, + Channels.python_discussion: 0, } # These channels will also work, but will not be subject to cooldown self.channel_whitelist = ( - Channels.bot, + Channels.bot_commands, ) # Stores improperly formatted Python codeblock message ids and the corresponding bot message diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index 2104efe57..5cdf0b048 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -173,7 +173,7 @@ class Clean(Cog): colour=Colour(Colours.soft_red), title="Bulk message delete", text=message, - channel_id=Channels.modlog, + channel_id=Channels.mod_log, ) @group(invoke_without_command=True, name="clean", aliases=["purge"]) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index c7ea1f2bf..050760a71 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -68,7 +68,7 @@ class Defcon(Cog): except Exception: # Yikes! log.exception("Unable to get DEFCON settings!") - await self.bot.get_channel(Channels.devlog).send( + await self.bot.get_channel(Channels.dev_log).send( f"<@&{Roles.admins}> **WARNING**: Unable to get DEFCON settings!" ) diff --git a/bot/cogs/free.py b/bot/cogs/free.py index 49cab6172..02c02d067 100644 --- a/bot/cogs/free.py +++ b/bot/cogs/free.py @@ -22,7 +22,7 @@ class Free(Cog): PYTHON_HELP_ID = Categories.python_help @command(name="free", aliases=('f',)) - @redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES) + @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES) async def free(self, ctx: Context, user: Member = None, seek: int = 2) -> None: """ Lists free help channels by likeliness of availability. diff --git a/bot/cogs/help.py b/bot/cogs/help.py index fd5bbc3ca..744722220 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -507,7 +507,7 @@ class Help(DiscordCog): """Custom Embed Pagination Help feature.""" @commands.command('help') - @redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES) + @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES) async def new_help(self, ctx: Context, *commands) -> None: """Shows Command Help.""" try: diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 13c8aabaa..49beca15b 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -152,8 +152,8 @@ class Information(Cog): # Non-staff may only do this in #bot-commands if not with_role_check(ctx, *constants.STAFF_ROLES): - if not ctx.channel.id == constants.Channels.bot: - raise InChannelCheckFailure(constants.Channels.bot) + if not ctx.channel.id == constants.Channels.bot_commands: + raise InChannelCheckFailure(constants.Channels.bot_commands) embed = await self.create_user_embed(ctx, user) @@ -332,7 +332,7 @@ class Information(Cog): @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES) @group(invoke_without_command=True) - @in_channel(constants.Channels.bot, bypass_roles=constants.STAFF_ROLES) + @in_channel(constants.Channels.bot_commands, bypass_roles=constants.STAFF_ROLES) async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None: """Shows information about the raw API response.""" # I *guess* it could be deleted right as the command is invoked but I felt like it wasn't worth handling diff --git a/bot/cogs/logging.py b/bot/cogs/logging.py index d1b7dcab3..9dcb1456b 100644 --- a/bot/cogs/logging.py +++ b/bot/cogs/logging.py @@ -34,7 +34,7 @@ class Logging(Cog): ) if not DEBUG_MODE: - await self.bot.get_channel(Channels.devlog).send(embed=embed) + await self.bot.get_channel(Channels.dev_log).send(embed=embed) def setup(bot: Bot) -> None: diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index e8ae0dbe6..94e646248 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -87,7 +87,7 @@ class ModLog(Cog, name="ModLog"): title: t.Optional[str], text: str, thumbnail: t.Optional[t.Union[str, discord.Asset]] = None, - channel_id: int = Channels.modlog, + channel_id: int = Channels.mod_log, ping_everyone: bool = False, files: t.Optional[t.List[discord.File]] = None, content: t.Optional[str] = None, @@ -377,7 +377,7 @@ class ModLog(Cog, name="ModLog"): Icons.user_ban, Colours.soft_red, "User banned", f"{member} (`{member.id}`)", thumbnail=member.avatar_url_as(static_format="png"), - channel_id=Channels.userlog + channel_id=Channels.user_log ) @Cog.listener() @@ -399,7 +399,7 @@ class ModLog(Cog, name="ModLog"): Icons.sign_in, Colours.soft_green, "User joined", message, thumbnail=member.avatar_url_as(static_format="png"), - channel_id=Channels.userlog + channel_id=Channels.user_log ) @Cog.listener() @@ -416,7 +416,7 @@ class ModLog(Cog, name="ModLog"): Icons.sign_out, Colours.soft_red, "User left", f"{member} (`{member.id}`)", thumbnail=member.avatar_url_as(static_format="png"), - channel_id=Channels.userlog + channel_id=Channels.user_log ) @Cog.listener() @@ -433,7 +433,7 @@ class ModLog(Cog, name="ModLog"): Icons.user_unban, Colour.blurple(), "User unbanned", f"{member} (`{member.id}`)", thumbnail=member.avatar_url_as(static_format="png"), - channel_id=Channels.modlog + channel_id=Channels.mod_log ) @Cog.listener() @@ -529,7 +529,7 @@ class ModLog(Cog, name="ModLog"): Icons.user_update, Colour.blurple(), "Member updated", message, thumbnail=after.avatar_url_as(static_format="png"), - channel_id=Channels.userlog + channel_id=Channels.user_log ) @Cog.listener() diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 84457e38f..aef12546d 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -177,7 +177,7 @@ class Snekbox(Cog): @command(name="eval", aliases=("e",)) @guild_only() - @in_channel(Channels.bot, hidden_channels=(Channels.esoteric,), bypass_roles=EVAL_ROLES) + @in_channel(Channels.bot_commands, hidden_channels=(Channels.esoteric,), bypass_roles=EVAL_ROLES) async def eval_command(self, ctx: Context, *, code: str = None) -> None: """ Run Python code and get the results. diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 2c4fa02bd..5da9a4148 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -15,7 +15,7 @@ from bot.pagination import LinePaginator log = logging.getLogger(__name__) TEST_CHANNELS = ( - Channels.bot, + Channels.bot_commands, Channels.helpers ) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index da278011a..94b9d6b5a 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -89,7 +89,7 @@ class Utils(Cog): await ctx.message.channel.send(embed=pep_embed) @command() - @in_channel(Channels.bot, bypass_roles=STAFF_ROLES) + @in_channel(Channels.bot_commands, bypass_roles=STAFF_ROLES) async def charinfo(self, ctx: Context, *, characters: str) -> None: """Shows you information on up to 25 unicode characters.""" match = re.match(r"<(a?):(\w+):(\d+)>", characters) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 09bef80c4..94bef3188 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -30,10 +30,11 @@ your information removed here as well. Feel free to review them at any point! Additionally, if you'd like to receive notifications for the announcements we post in <#{Channels.announcements}> \ -from time to time, you can send `!subscribe` to <#{Channels.bot}> at any time to assign yourself the \ +from time to time, you can send `!subscribe` to <#{Channels.bot_commands}> at any time to assign yourself the \ **Announcements** role. We'll mention this role every time we make an announcement. -If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to <#{Channels.bot}>. +If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to \ +<#{Channels.bot_commands}>. """ PERIODIC_PING = ( @@ -136,7 +137,7 @@ class Verification(Cog): await ctx.message.delete() @command(name='subscribe') - @in_channel(Channels.bot) + @in_channel(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 @@ -160,7 +161,7 @@ class Verification(Cog): ) @command(name='unsubscribe') - @in_channel(Channels.bot) + @in_channel(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 diff --git a/bot/constants.py b/bot/constants.py index 98914fb9d..f35d608da 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -362,10 +362,10 @@ class Channels(metaclass=YAMLGetter): announcements: int attachment_log: int big_brother_logs: int - bot: int + bot_commands: int defcon: int devcontrib: int - devlog: int + dev_log: int esoteric: int help_0: int help_1: int @@ -381,16 +381,16 @@ class Channels(metaclass=YAMLGetter): mod_spam: int mods: int mod_alerts: int - modlog: int + mod_log: int off_topic_0: int off_topic_1: int off_topic_2: int organisation: int - python: int + python_discussion: int reddit: int talent_pool: int - userlog: int - user_event_a: int + user_log: int + user_event_announcements: int verification: int voice_log: int diff --git a/config-default.yml b/config-default.yml index 10cc34816..44da694c2 100644 --- a/config-default.yml +++ b/config-default.yml @@ -110,69 +110,69 @@ guild: id: 267624335836053506 categories: - python_help: 356013061213126657 + python_help: 356013061213126657 channels: - announcements: 354619224620138496 - user_event_a: &USER_EVENT_A 592000283102674944 + announcements: 354619224620138496 + user_event_announcements: &USER_EVENT_A 592000283102674944 # Development - devcontrib: &DEV_CONTRIB 635950537262759947 - devlog: &DEVLOG 622895325144940554 + devcontrib: &DEV_CONTRIB 635950537262759947 + dev_log: &DEVLOG 622895325144940554 # Discussion - meta: 429409067623251969 - python: 267624335836053506 + meta: 429409067623251969 + python_discussion: 267624335836053506 # Logs - attachment_log: &ATTCH_LOG 649243850006855680 - message_log: &MESSAGE_LOG 467752170159079424 - modlog: &MODLOG 282638479504965634 - userlog: 528976905546760203 - voice_log: 640292421988646961 + attachment_log: &ATTACH_LOG 649243850006855680 + message_log: &MESSAGE_LOG 467752170159079424 + mod_log: &MOD_LOG 282638479504965634 + user_log: 528976905546760203 + voice_log: 640292421988646961 # Off-topic - off_topic_0: 291284109232308226 - off_topic_1: 463035241142026251 - off_topic_2: 463035268514185226 + off_topic_0: 291284109232308226 + off_topic_1: 463035241142026251 + off_topic_2: 463035268514185226 # Python Help - help_0: 303906576991780866 - help_1: 303906556754395136 - help_2: 303906514266226689 - help_3: 439702951246692352 - help_4: 451312046647148554 - help_5: 454941769734422538 - help_6: 587375753306570782 - help_7: 587375768556797982 + help_0: 303906576991780866 + help_1: 303906556754395136 + help_2: 303906514266226689 + help_3: 439702951246692352 + help_4: 451312046647148554 + help_5: 454941769734422538 + help_6: 587375753306570782 + help_7: 587375768556797982 # Special - bot: &BOT_CMD 267659945086812160 - esoteric: 470884583684964352 - reddit: 458224812528238616 - verification: 352442727016693763 + bot_commands: &BOT_CMD 267659945086812160 + esoteric: 470884583684964352 + reddit: 458224812528238616 + verification: 352442727016693763 # Staff - admins: &ADMINS 365960823622991872 - admin_spam: &ADMIN_SPAM 563594791770914816 - defcon: &DEFCON 464469101889454091 - helpers: &HELPERS 385474242440986624 - mods: &MODS 305126844661760000 - mod_alerts: 473092532147060736 - mod_spam: &MOD_SPAM 620607373828030464 - organisation: &ORGANISATION 551789653284356126 - staff_lounge: &STAFF_LOUNGE 464905259261755392 + admins: &ADMINS 365960823622991872 + admin_spam: &ADMIN_SPAM 563594791770914816 + defcon: &DEFCON 464469101889454091 + helpers: &HELPERS 385474242440986624 + mods: &MODS 305126844661760000 + mod_alerts: 473092532147060736 + mod_spam: &MOD_SPAM 620607373828030464 + organisation: &ORGANISATION 551789653284356126 + staff_lounge: &STAFF_LOUNGE 464905259261755392 # Voice - admins_voice: &ADMINS_VOICE 500734494840717332 - staff_voice: &STAFF_VOICE 412375055910043655 + admins_voice: &ADMINS_VOICE 500734494840717332 + staff_voice: &STAFF_VOICE 412375055910043655 # Watch - big_brother_logs: &BBLOGS 468507907357409333 - talent_pool: &TALENT_POOL 534321732593647616 + big_brother_logs: &BB_LOGS 468507907357409333 + talent_pool: &TALENT_POOL 534321732593647616 staff_channels: [*ADMINS, *ADMIN_SPAM, *MOD_SPAM, *MODS, *HELPERS, *ORGANISATION, *DEFCON] - ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG, *ADMINS_VOICE, *STAFF_VOICE, *ATTCH_LOG] + ignored: [*ADMINS, *MESSAGE_LOG, *MOD_LOG, *ADMINS_VOICE, *STAFF_VOICE, *ATTACH_LOG] reminder_whitelist: [*BOT_CMD, *DEV_CONTRIB] roles: @@ -193,8 +193,8 @@ guild: owners: &OWNERS_ROLE 267627879762755584 # Code Jam - jammers: 591786436651646989 - team_leaders: 501324292341104650 + jammers: 591786436651646989 + team_leaders: 501324292341104650 webhooks: talent_pool: 569145364800602132 @@ -278,12 +278,11 @@ filter: # Censor doesn't apply to these channel_whitelist: - *ADMINS - - *MODLOG + - *MOD_LOG - *MESSAGE_LOG - - *DEVLOG - - *BBLOGS + - *DEV_LOG + - *BB_LOGS - *STAFF_LOUNGE - - *DEVTEST - *TALENT_POOL - *USER_EVENT_A -- cgit v1.2.3 From b387b2ec3201a33a0f2ba46a032477b126479671 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 12:23:25 -0800 Subject: Always load doc and verification extensions They used to only be loaded in "debug mode" because the main guild was used to test the bot. However, we have since moved to using a separate test guild so it's no longer a concern if these cogs get loaded. It was confusing to some contributors as to why these cogs were not being loaded since the debug mode isn't really documented anywhere. --- bot/__main__.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/bot/__main__.py b/bot/__main__.py index 490163739..0079a9381 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -7,7 +7,7 @@ from sentry_sdk.integrations.logging import LoggingIntegration from bot import patches from bot.bot import Bot -from bot.constants import Bot as BotConfig, DEBUG_MODE +from bot.constants import Bot as BotConfig sentry_logging = LoggingIntegration( level=logging.TRACE, @@ -40,10 +40,8 @@ bot.load_extension("bot.cogs.clean") bot.load_extension("bot.cogs.extensions") bot.load_extension("bot.cogs.help") -# Only load this in production -if not DEBUG_MODE: - bot.load_extension("bot.cogs.doc") - bot.load_extension("bot.cogs.verification") +bot.load_extension("bot.cogs.doc") +bot.load_extension("bot.cogs.verification") # Feature cogs bot.load_extension("bot.cogs.alias") -- cgit v1.2.3 From 92e4e6d250d6de1b164d36b8a16c62dd4c71fa4f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 12:40:21 -0800 Subject: Config: fix DEV_LOG variable thingy --- config-default.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config-default.yml b/config-default.yml index 44da694c2..51efe4d9a 100644 --- a/config-default.yml +++ b/config-default.yml @@ -117,8 +117,8 @@ guild: user_event_announcements: &USER_EVENT_A 592000283102674944 # Development - devcontrib: &DEV_CONTRIB 635950537262759947 - dev_log: &DEVLOG 622895325144940554 + devcontrib: &DEV_CONTRIB 635950537262759947 + dev_log: &DEV_LOG 622895325144940554 # Discussion meta: 429409067623251969 -- cgit v1.2.3 From 14536f873d5d233880a88c9f71710ef7c2061625 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 12:40:57 -0800 Subject: Config: add underscore to devcontrib --- bot/constants.py | 2 +- config-default.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index f35d608da..63f7b15ee 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -364,7 +364,7 @@ class Channels(metaclass=YAMLGetter): big_brother_logs: int bot_commands: int defcon: int - devcontrib: int + dev_contrib: int dev_log: int esoteric: int help_0: int diff --git a/config-default.yml b/config-default.yml index 51efe4d9a..a43610562 100644 --- a/config-default.yml +++ b/config-default.yml @@ -117,7 +117,7 @@ guild: user_event_announcements: &USER_EVENT_A 592000283102674944 # Development - devcontrib: &DEV_CONTRIB 635950537262759947 + dev_contrib: &DEV_CONTRIB 635950537262759947 dev_log: &DEV_LOG 622895325144940554 # Discussion -- cgit v1.2.3 From 7c14787a9b1550328180ac3ce3da4d9faa65f41e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 13:05:36 -0800 Subject: Config: replace abbreviated lists with normal ones Lists were getting too long to be readable as one line. Having each element on a separate line also reduces merge conflicts. --- config-default.yml | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/config-default.yml b/config-default.yml index a43610562..05059fbee 100644 --- a/config-default.yml +++ b/config-default.yml @@ -171,9 +171,26 @@ guild: big_brother_logs: &BB_LOGS 468507907357409333 talent_pool: &TALENT_POOL 534321732593647616 - staff_channels: [*ADMINS, *ADMIN_SPAM, *MOD_SPAM, *MODS, *HELPERS, *ORGANISATION, *DEFCON] - ignored: [*ADMINS, *MESSAGE_LOG, *MOD_LOG, *ADMINS_VOICE, *STAFF_VOICE, *ATTACH_LOG] - reminder_whitelist: [*BOT_CMD, *DEV_CONTRIB] + staff_channels: + - *ADMINS + - *ADMIN_SPAM + - *DEFCON + - *HELPERS + - *MODS + - *MOD_SPAM + - *ORGANISATION + + ignored: + - *ADMINS + - *ADMINS_VOICE + - *ATTACH_LOG + - *MESSAGE_LOG + - *MOD_LOG + - *STAFF_VOICE + + reminder_whitelist: + - *BOT_CMD + - *DEV_CONTRIB roles: announcements: 463658397560995840 @@ -454,7 +471,20 @@ redirect_output: 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] + 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 config: required_keys: ['bot.token'] -- cgit v1.2.3 From 66fa960fcf0f1d11af20ec1c77039e9ca791f4dc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 13:08:40 -0800 Subject: Constants: rename Guild.Constant.ignored to modlog_blacklist This name better explains what the list is for. --- bot/cogs/moderation/modlog.py | 10 +++++----- bot/constants.py | 2 +- config-default.yml | 3 ++- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 94e646248..59ae6b587 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -538,7 +538,7 @@ class ModLog(Cog, name="ModLog"): channel = message.channel author = message.author - if message.guild.id != GuildConstant.id or channel.id in GuildConstant.ignored: + if message.guild.id != GuildConstant.id or channel.id in GuildConstant.modlog_blacklist: return self._cached_deletes.append(message.id) @@ -591,7 +591,7 @@ class ModLog(Cog, name="ModLog"): @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.ignored: + 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 @@ -635,7 +635,7 @@ class ModLog(Cog, name="ModLog"): if ( not msg_before.guild or msg_before.guild.id != GuildConstant.id - or msg_before.channel.id in GuildConstant.ignored + or msg_before.channel.id in GuildConstant.modlog_blacklist or msg_before.author.bot ): return @@ -717,7 +717,7 @@ class ModLog(Cog, name="ModLog"): if ( not message.guild or message.guild.id != GuildConstant.id - or message.channel.id in GuildConstant.ignored + or message.channel.id in GuildConstant.modlog_blacklist or message.author.bot ): return @@ -769,7 +769,7 @@ class ModLog(Cog, name="ModLog"): """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.ignored) + or (before.channel and before.channel.id in GuildConstant.modlog_blacklist) ): return diff --git a/bot/constants.py b/bot/constants.py index 63f7b15ee..9855421c9 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -428,7 +428,7 @@ class Guild(metaclass=YAMLGetter): section = "guild" id: int - ignored: List[int] + modlog_blacklist: List[int] staff_channels: List[int] reminder_whitelist: List[int] diff --git a/config-default.yml b/config-default.yml index 05059fbee..b253f32e8 100644 --- a/config-default.yml +++ b/config-default.yml @@ -180,7 +180,8 @@ guild: - *MOD_SPAM - *ORGANISATION - ignored: + # Modlog cog ignores events which occur in these channels + modlog_blacklist: - *ADMINS - *ADMINS_VOICE - *ATTACH_LOG -- cgit v1.2.3 From 12c7d2794e8e9d086f5d52c8916df26bb9de5979 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 13:29:08 -0800 Subject: Tests: fix setting bot-commands ID in information tests --- 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 38293269f..8443cfe71 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -521,7 +521,7 @@ 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 = 50 + constants.Channels.bot_commands = 50 ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=100)) @@ -533,7 +533,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 = 50 + constants.Channels.bot_commands = 50 ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) @@ -546,7 +546,7 @@ class UserCommandTests(unittest.TestCase): 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 = 50 + constants.Channels.bot_commands = 50 ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) @@ -559,7 +559,7 @@ 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 = 50 + constants.Channels.bot_commands = 50 ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=200)) -- cgit v1.2.3 From f1c987978d7c66a7886c19a40a00fa9d2b8c7d0c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 15:35:59 -0800 Subject: Sync: code style refactoring * Convert diff namedtuple to dict outside the dict comprehension * Define long condition as a boolean instead of in the if statement * Pass role and user dicts to aiohttp normally instead of unpacking --- bot/cogs/sync/cog.py | 6 ++++-- bot/cogs/sync/syncers.py | 11 ++++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index ee3cccbfa..5708be3f4 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -65,12 +65,14 @@ class Sync(Cog): @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 ( + 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={ diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 43a8f2b62..6715ad6fb 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -192,7 +192,8 @@ class Syncer(abc.ABC): author = ctx.author diff = await self._get_diff(guild) - totals = {k: len(v) for k, v in diff._asdict().items() if v is not None} + 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) @@ -261,11 +262,11 @@ class RoleSyncer(Syncer): """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()}) + 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()}) + await self.bot.api_client.put(f'bot/roles/{role.id}', json=role._asdict()) log.trace("Syncing deleted roles...") for role in diff.deleted: @@ -334,8 +335,8 @@ class UserSyncer(Syncer): """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()}) + 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()}) + await self.bot.api_client.put(f'bot/users/{user.id}', json=user._asdict()) -- cgit v1.2.3 From 8a3063be1764307d05ae0215b00f53b06ed33f6c Mon Sep 17 00:00:00 2001 From: Numerlor Date: Mon, 24 Feb 2020 01:30:50 +0100 Subject: Implement `__iter__` on constants YAMLGetter. Python tries to fall back on passing indices to `__getitem__` without iter implemented; failing on the first line. --- bot/constants.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index a4c65a1f8..3ecdb5b35 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -186,6 +186,10 @@ class YAMLGetter(type): def __getitem__(cls, name): return cls.__getattr__(name) + def __iter__(cls): + """Returns iterator of key: value pairs of current constants class.""" + return iter(_CONFIG_YAML[cls.section][cls.subsection].items()) + # Dataclasses class Bot(metaclass=YAMLGetter): -- cgit v1.2.3 From 08f6ed038fa4be5ed09227114902a52d72a73155 Mon Sep 17 00:00:00 2001 From: Numerlor Date: Mon, 24 Feb 2020 01:33:23 +0100 Subject: Add ConfigVerifier cog. Adds ConfigVerifier which verifies channels when loaded. --- bot/__main__.py | 1 + bot/cogs/config_verifier.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 bot/cogs/config_verifier.py diff --git a/bot/__main__.py b/bot/__main__.py index 490163739..79f89b467 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -31,6 +31,7 @@ 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") diff --git a/bot/cogs/config_verifier.py b/bot/cogs/config_verifier.py new file mode 100644 index 000000000..f0aaa06ea --- /dev/null +++ b/bot/cogs/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.bot.loop.create_task(self.verify_channels()) + + async def verify_channels(self) -> None: + """ + Verifies channels in config. + + If any channels in config aren't present in server, log them in a warning. + """ + await self.bot.wait_until_ready() + 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"Channels do not exist in server: {', '.join(invalid_channels)}.") + + +def setup(bot: Bot) -> None: + """Load the ConfigVerifier cog.""" + bot.add_cog(ConfigVerifier(bot)) -- cgit v1.2.3 From a1ad4ae66bfa14972f9ea686a728e8060bfe55e0 Mon Sep 17 00:00:00 2001 From: Numerlor Date: Mon, 24 Feb 2020 01:43:43 +0100 Subject: Change warning text. --- bot/cogs/config_verifier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/config_verifier.py b/bot/cogs/config_verifier.py index f0aaa06ea..d2bc81ba6 100644 --- a/bot/cogs/config_verifier.py +++ b/bot/cogs/config_verifier.py @@ -32,7 +32,7 @@ class ConfigVerifier(Cog): ] if invalid_channels: - log.warning(f"Channels do not exist in server: {', '.join(invalid_channels)}.") + log.warning(f"Configured channels do not exist in server: {', '.join(invalid_channels)}.") def setup(bot: Bot) -> None: -- cgit v1.2.3 From 62a232b3a55a7cc983487ac165a4a9bbd6d6e3f9 Mon Sep 17 00:00:00 2001 From: Numerlor Date: Mon, 24 Feb 2020 16:56:35 +0100 Subject: Change docstring mood. --- bot/cogs/config_verifier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/config_verifier.py b/bot/cogs/config_verifier.py index d2bc81ba6..cc19f7423 100644 --- a/bot/cogs/config_verifier.py +++ b/bot/cogs/config_verifier.py @@ -18,7 +18,7 @@ class ConfigVerifier(Cog): async def verify_channels(self) -> None: """ - Verifies channels in config. + Verify channels. If any channels in config aren't present in server, log them in a warning. """ -- cgit v1.2.3 From 6174792c01f238e32aca5cc9222caa4feb788281 Mon Sep 17 00:00:00 2001 From: Numerlor Date: Mon, 24 Feb 2020 19:45:09 +0100 Subject: Remove unused `chunks` function and its tests. The function was only used in the since removed `Events` cog. --- bot/utils/__init__.py | 12 +----------- tests/bot/test_utils.py | 15 --------------- 2 files changed, 1 insertion(+), 26 deletions(-) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 8184be824..3e4b15ce4 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1,5 +1,5 @@ from abc import ABCMeta -from typing import Any, Generator, Hashable, Iterable +from typing import Any, Hashable from discord.ext.commands import CogMeta @@ -64,13 +64,3 @@ class CaseInsensitiveDict(dict): for k in list(self.keys()): v = super(CaseInsensitiveDict, self).pop(k) self.__setitem__(k, v) - - -def chunks(iterable: Iterable, size: int) -> Generator[Any, None, None]: - """ - Generator that allows you to iterate over any indexable collection in `size`-length chunks. - - Found: https://stackoverflow.com/a/312464/4022104 - """ - for i in range(0, len(iterable), size): - yield iterable[i:i + size] diff --git a/tests/bot/test_utils.py b/tests/bot/test_utils.py index 58ae2a81a..d7bcc3ba6 100644 --- a/tests/bot/test_utils.py +++ b/tests/bot/test_utils.py @@ -35,18 +35,3 @@ class CaseInsensitiveDictTests(unittest.TestCase): instance = utils.CaseInsensitiveDict() instance.update({'FOO': 'bar'}) self.assertEqual(instance['foo'], 'bar') - - -class ChunkTests(unittest.TestCase): - """Tests the `chunk` method.""" - - def test_empty_chunking(self): - """Tests chunking on an empty iterable.""" - generator = utils.chunks(iterable=[], size=5) - self.assertEqual(list(generator), []) - - def test_list_chunking(self): - """Tests chunking a non-empty list.""" - iterable = [1, 2, 3, 4, 5] - generator = utils.chunks(iterable=iterable, size=2) - self.assertEqual(list(generator), [[1, 2], [3, 4], [5]]) -- cgit v1.2.3 From b4ed7107d162d1961ae4dc03cdda282123fbb877 Mon Sep 17 00:00:00 2001 From: Numerlor Date: Mon, 24 Feb 2020 22:22:11 +0100 Subject: Do not attempt to load Reddit cog when environment variables are not provided. When environment variables weren't provided; the cog attempted to create a BasicAuth object with None as values resulting in an exception before the event loop was started and a subsequent crash. --- bot/cogs/reddit.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index aa487f18e..dce73fcf2 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -290,4 +290,7 @@ class Reddit(Cog): def setup(bot: Bot) -> None: """Load the Reddit cog.""" - bot.add_cog(Reddit(bot)) + if None not in (RedditConfig.client_id, RedditConfig.secret): + bot.add_cog(Reddit(bot)) + return + log.error("Credentials not provided, cog not loaded.") -- cgit v1.2.3 From daf50941ca6ceaa0b65d71cd9fee1ad2a67e1718 Mon Sep 17 00:00:00 2001 From: Numerlor Date: Tue, 25 Feb 2020 14:19:00 +0100 Subject: Wait for available guild instead of bot startup. Co-authored-by: SebastiaanZ <33516116+SebastiaanZ@users.noreply.github.com> --- bot/cogs/config_verifier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/config_verifier.py b/bot/cogs/config_verifier.py index cc19f7423..b43b48264 100644 --- a/bot/cogs/config_verifier.py +++ b/bot/cogs/config_verifier.py @@ -22,7 +22,7 @@ class ConfigVerifier(Cog): If any channels in config aren't present in server, log them in a warning. """ - await self.bot.wait_until_ready() + 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} -- cgit v1.2.3 From d6ef05c28021db5087ce1a27a108c35c276b915f Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 25 Feb 2020 14:32:32 +0100 Subject: Assign created task to a variable. Co-authored-by: SebastiaanZ <33516116+SebastiaanZ@users.noreply.github.com> --- bot/cogs/config_verifier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/config_verifier.py b/bot/cogs/config_verifier.py index b43b48264..d72c6c22e 100644 --- a/bot/cogs/config_verifier.py +++ b/bot/cogs/config_verifier.py @@ -14,7 +14,7 @@ class ConfigVerifier(Cog): def __init__(self, bot: Bot): self.bot = bot - self.bot.loop.create_task(self.verify_channels()) + self.channel_verify_task = self.bot.loop.create_task(self.verify_channels()) async def verify_channels(self) -> None: """ -- cgit v1.2.3 From 3b3206471c028f87685c4c07db0c167a7066ced2 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Tue, 25 Feb 2020 12:28:10 -0500 Subject: Configure staff role & channel groupings in YAML Delete duplicate keys that were missed in the merge --- bot/constants.py | 11 +++++++---- config-default.yml | 26 ++++++++++++++++++++------ 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 285761055..b1713aa60 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -431,9 +431,12 @@ class Guild(metaclass=YAMLGetter): section = "guild" id: int + moderation_channels: List[int] + moderation_roles: List[int] modlog_blacklist: List[int] - staff_channels: List[int] reminder_whitelist: List[int] + staff_channels: List[int] + staff_roles: List[int] class Keys(metaclass=YAMLGetter): section = "keys" @@ -579,14 +582,14 @@ BOT_DIR = os.path.dirname(__file__) PROJECT_ROOT = os.path.abspath(os.path.join(BOT_DIR, os.pardir)) # Default role combinations -MODERATION_ROLES = Roles.moderators, Roles.admins, Roles.owners -STAFF_ROLES = Roles.helpers, Roles.moderators, Roles.admins, Roles.owners +MODERATION_ROLES = Guild.moderation_roles +STAFF_ROLES = Guild.staff_roles # Roles combinations STAFF_CHANNELS = Guild.staff_channels # Default Channel combinations -MODERATION_CHANNELS = Channels.admins, Channels.admin_spam, Channels.mod_alerts, Channels.mods, Channels.mod_spam +MODERATION_CHANNELS = Guild.moderation_channels # Bot replies diff --git a/config-default.yml b/config-default.yml index dca00500e..ab237423f 100644 --- a/config-default.yml +++ b/config-default.yml @@ -160,7 +160,7 @@ guild: defcon: &DEFCON 464469101889454091 helpers: &HELPERS 385474242440986624 mods: &MODS 305126844661760000 - mod_alerts: 473092532147060736 + mod_alerts: &MOD_ALERTS 473092532147060736 mod_spam: &MOD_SPAM 620607373828030464 organisation: &ORGANISATION 551789653284356126 staff_lounge: &STAFF_LOUNGE 464905259261755392 @@ -182,6 +182,13 @@ guild: - *MOD_SPAM - *ORGANISATION + moderation_channels: + - *ADMINS + - *ADMIN_SPAM + - *MOD_ALERTS + - *MODS + - *MOD_SPAM + # Modlog cog ignores events which occur in these channels modlog_blacklist: - *ADMINS @@ -195,10 +202,6 @@ guild: - *BOT_CMD - *DEV_CONTRIB - staff_channels: [*ADMINS, *ADMIN_SPAM, *MOD_SPAM, *MODS, *HELPERS, *ORGANISATION, *DEFCON] - ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG, *ADMINS_VOICE, *STAFF_VOICE, *ATTCH_LOG] - reminder_whitelist: [*BOT_CMD, *DEV_CONTRIB] - roles: announcements: 463658397560995840 contributors: 295488872404484098 @@ -212,7 +215,7 @@ guild: # Staff admins: &ADMINS_ROLE 267628507062992896 core_developers: 587606783669829632 - helpers: 267630620367257601 + helpers: &HELPERS_ROLE 267630620367257601 moderators: &MODS_ROLE 267629731250176001 owners: &OWNERS_ROLE 267627879762755584 @@ -220,6 +223,17 @@ guild: jammers: 591786436651646989 team_leaders: 501324292341104650 + moderation_roles: + - *OWNERS_ROLE + - *ADMINS_ROLE + - *MODS_ROLE + + staff_roles: + - *OWNERS_ROLE + - *ADMINS_ROLE + - *MODS_ROLE + - *HELPERS_ROLE + webhooks: talent_pool: 569145364800602132 big_brother: 569133704568373283 -- cgit v1.2.3 From 284c1de321fea5927dafc1ac3192ad763bda3203 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Tue, 25 Feb 2020 12:47:09 -0500 Subject: Fix mismatched constant names in syncer tests --- bot/cogs/sync/syncers.py | 8 ++++---- tests/bot/cogs/sync/test_base.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 6715ad6fb..d6891168f 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -23,7 +23,7 @@ _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_developer}> " + _CORE_DEV_MENTION = f"<@&{constants.Roles.core_developers}> " _REACTION_EMOJIS = (constants.Emojis.check_mark, constants.Emojis.cross_mark) def __init__(self, bot: Bot) -> None: @@ -54,12 +54,12 @@ class Syncer(abc.ABC): # 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.devcore) + 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.devcore) + channel = await self.bot.fetch_channel(constants.Channels.dev_core) except HTTPException: log.exception( f"Failed to fetch channel for sending sync confirmation prompt; " @@ -93,7 +93,7 @@ class Syncer(abc.ABC): `author` of the prompt. """ # For automatic syncs, check for the core dev role instead of an exact author - has_role = any(constants.Roles.core_developer == role.id for role in user.roles) + has_role = any(constants.Roles.core_developers == role.id for role in user.roles) return ( reaction.message.id == message.id and not user.bot diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index e6a6f9688..c2e143865 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -84,7 +84,7 @@ class SyncerSendPromptTests(unittest.TestCase): mock_() await self.syncer._send_prompt() - method.assert_called_once_with(constants.Channels.devcore) + method.assert_called_once_with(constants.Channels.dev_core) @helpers.async_test async def test_send_prompt_returns_None_if_channel_fetch_fails(self): @@ -135,7 +135,7 @@ class SyncerConfirmationTests(unittest.TestCase): def setUp(self): self.bot = helpers.MockBot() self.syncer = TestSyncer(self.bot) - self.core_dev_role = helpers.MockRole(id=constants.Roles.core_developer) + self.core_dev_role = helpers.MockRole(id=constants.Roles.core_developers) @staticmethod def get_message_reaction(emoji): -- cgit v1.2.3 From 33302afd9e83cb4b5502a8b5bbe43bac450dba3f Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 25 Feb 2020 22:54:03 +0100 Subject: Fix `__iter__` for classes without subsections. The previous implementation assumed the config class was a subsection, failing with a KeyError if it wasn't one. Co-authored-by: kwzrd --- bot/constants.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 3776ceb84..ebd3b3d96 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -187,8 +187,9 @@ class YAMLGetter(type): return cls.__getattr__(name) def __iter__(cls): - """Returns iterator of key: value pairs of current constants class.""" - return iter(_CONFIG_YAML[cls.section][cls.subsection].items()) + """Return generator of key: value pairs of current constants class' config values.""" + for name in cls.__annotations__: + yield name, getattr(cls, name) # Dataclasses -- cgit v1.2.3 From 1f82ed36f24f2ffbef5b3601fb6c11db28735c71 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 25 Feb 2020 22:57:52 +0100 Subject: Restyle if body to include the error instead of adding the cog. --- bot/cogs/reddit.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index e93e4de0c..3278363ba 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -290,7 +290,7 @@ class Reddit(Cog): def setup(bot: Bot) -> None: """Load the Reddit cog.""" - if None not in (RedditConfig.client_id, RedditConfig.secret): - bot.add_cog(Reddit(bot)) + if None in (RedditConfig.client_id, RedditConfig.secret): + log.error("Credentials not provided, cog not loaded.") return - log.error("Credentials not provided, cog not loaded.") + bot.add_cog(Reddit(bot)) -- cgit v1.2.3 From 6a2a2b5c3da79fe0097c98a04e435e493c73223d Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 26 Feb 2020 00:29:52 +0100 Subject: Check for empty strings alongside None before loading cog. Docker fetches values from the .env itself and defaults to "" instead of None, needing to do invalid access token requests before unloading itself. --- bot/cogs/reddit.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 3278363ba..7cb340145 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -290,7 +290,8 @@ class Reddit(Cog): def setup(bot: Bot) -> None: """Load the Reddit cog.""" - if None in (RedditConfig.client_id, RedditConfig.secret): + invalid_values = "", None + if any(value in (RedditConfig.secret, RedditConfig.client_id) for value in invalid_values): log.error("Credentials not provided, cog not loaded.") return bot.add_cog(Reddit(bot)) -- cgit v1.2.3 From e8e2fa9ee8f607bb6593b7c8325446dc074a972d Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 26 Feb 2020 00:33:29 +0100 Subject: Make sure token exists before checking its expiration. Without the check and an invalid token, an AttributeError is raised; blocking the relevant ClientError from being raised in `get_access_token`. --- 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 7cb340145..982c0cbe6 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -43,7 +43,7 @@ class Reddit(Cog): 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.expires_at < datetime.utcnow(): + if self.access_token and self.access_token.expires_at < datetime.utcnow(): self.revoke_access_token() async def init_reddit_ready(self) -> None: -- cgit v1.2.3 From d91f821fccfa61f324489baff5debbebb3adbb51 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 26 Feb 2020 08:42:30 +0100 Subject: Create task for `revoke_access_token` when unloading cog to ensure it's executed. --- 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 982c0cbe6..6fe7f820b 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -44,7 +44,7 @@ class Reddit(Cog): """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(): - self.revoke_access_token() + asyncio.create_task(self.revoke_access_token()) async def init_reddit_ready(self) -> None: """Sets the reddit webhook when the cog is loaded.""" -- cgit v1.2.3 From 5a0a04ea4fb4a9aa17e7f807e72f2bcd5e3e6349 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 26 Feb 2020 08:43:06 +0100 Subject: Specify the logged time is in UTC. --- 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 6fe7f820b..22e5c2a9c 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -83,7 +83,7 @@ class Reddit(Cog): expires_at=datetime.utcnow() + timedelta(seconds=expiration) ) - log.debug(f"New token acquired; expires on {self.access_token.expires_at}") + log.debug(f"New token acquired; expires on UTC {self.access_token.expires_at}") return else: log.debug( -- cgit v1.2.3 From f87a53ff442bc10cd2cba87943e40c531e0ce9ba Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 26 Feb 2020 10:43:38 +0100 Subject: Check for falsy values instead of ``""` and `None` explicitly. After the change to also check empty strings to avoid unucessary requests, it is no longer necessary to do an explicit value check, as the only values that can come from the .env file are `None` and strings Co-authored-by: Karlis S <45097959+ks129@users.noreply.github.com> --- bot/cogs/reddit.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 22e5c2a9c..6d03928e0 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -290,8 +290,7 @@ class Reddit(Cog): def setup(bot: Bot) -> None: """Load the Reddit cog.""" - invalid_values = "", None - if any(value in (RedditConfig.secret, RedditConfig.client_id) for value in invalid_values): + if not RedditConfig.secret or not RedditConfig.client_id: log.error("Credentials not provided, cog not loaded.") return bot.add_cog(Reddit(bot)) -- cgit v1.2.3 From c2d695fe196c49d3dfcae1186c1dafe13ba98e88 Mon Sep 17 00:00:00 2001 From: "Karlis. S" Date: Tue, 25 Feb 2020 20:01:15 +0200 Subject: Added to AntiMalware staff ignore check. --- bot/cogs/antimalware.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index 28e3e5d96..d2ff9f79c 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -4,7 +4,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, URLs +from bot.constants import AntiMalware as AntiMalwareConfig, Channels, STAFF_ROLES, URLs log = logging.getLogger(__name__) @@ -21,6 +21,10 @@ class AntiMalware(Cog): if not message.attachments: return + # Check if user is staff, if is, return + if hasattr(message.author, "roles") and any(role.id in STAFF_ROLES for role in message.author.roles): + return + embed = Embed() for attachment in message.attachments: filename = attachment.filename.lower() -- cgit v1.2.3 From f779f60b5376872043eda8c25712f0dd2a451a78 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 26 Feb 2020 18:20:19 +0100 Subject: Fix comparison operator when checking token expiration. With `<` the check only went through when the token was already expired, making revoking redundant; and didn't go through when the token still had some time before expiration. --- 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 6d03928e0..5a7fa100f 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -43,7 +43,7 @@ class Reddit(Cog): 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(): + 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: -- cgit v1.2.3 From b628c5b9a054af22851aa099f0656ed465472456 Mon Sep 17 00:00:00 2001 From: ks123 Date: Wed, 26 Feb 2020 19:34:01 +0200 Subject: Added DMs ignoring to antimalware check --- bot/cogs/antimalware.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index d2ff9f79c..957c458c0 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -18,7 +18,8 @@ class AntiMalware(Cog): @Cog.listener() async def on_message(self, message: Message) -> None: """Identify messages with prohibited attachments.""" - if not message.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 -- cgit v1.2.3 From 68f97584d0e472857f07f8421001e007d5983164 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 26 Feb 2020 19:00:52 +0100 Subject: Make sure tag name contains at least one letter. With only ascii and numbers being allowed to go through, possible values still included things like `$()` which don't match anything in `REGEX_NON_ALPHABET` from tags.py resulting in an error. --- bot/converters.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bot/converters.py b/bot/converters.py index cca57a02d..d73ab73f1 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -175,6 +175,12 @@ class TagNameConverter(Converter): "Rejecting the request.") raise BadArgument("Are you insane? That's way too long!") + # The tag name is ascii but does not contain any letters. + elif not any(character.isalpha() for character in tag_name): + log.warning(f"{ctx.author} tried to request a tag name without letters. " + "Rejecting the request.") + raise BadArgument("Tag names must contain at least one letter.") + return tag_name -- cgit v1.2.3 From b4a52aded317572d51a0747ed8d74b3fc84c9428 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 26 Feb 2020 19:13:48 +0100 Subject: Pass error handler tag fallback through TagNameConverter. The tag fallback didn't convert tags, resulting in possible invalid tag names being passed to the `tags_get_command`. This makes sure they're valid and ignores the risen exception if they are not. --- bot/cogs/error_handler.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 0abb7e521..3486a746c 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -20,6 +20,7 @@ from sentry_sdk import push_scope from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Channels +from bot.converters import TagNameConverter from bot.decorators import InChannelCheckFailure log = logging.getLogger(__name__) @@ -88,8 +89,11 @@ class ErrorHandler(Cog): return # Return to not raise the exception - with contextlib.suppress(ResponseCodeError): - await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with) + with contextlib.suppress(BadArgument, ResponseCodeError): + await ctx.invoke( + tags_get_command, + tag_name=await TagNameConverter.convert(ctx, ctx.invoked_with) + ) return elif isinstance(e, BadArgument): await ctx.send(f"Bad argument: {e}\n") -- cgit v1.2.3 From 432311ce720a1aea23c3ed7b422615a2e304b070 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 26 Feb 2020 19:22:28 +0100 Subject: Remove number check on tags. This case is already covered by checking if at least one letter is included. --- bot/converters.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index d73ab73f1..745ce5b5d 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -141,14 +141,6 @@ class TagNameConverter(Converter): @staticmethod async def convert(ctx: Context, tag_name: str) -> str: """Lowercase & strip whitespace from proposed tag_name & ensure it's valid.""" - def is_number(value: str) -> bool: - """Check to see if the input string is numeric.""" - try: - float(value) - except ValueError: - return False - return True - tag_name = tag_name.lower().strip() # The tag name has at least one invalid character. @@ -163,12 +155,6 @@ class TagNameConverter(Converter): "Rejecting the request.") raise BadArgument("Tag names should not be empty, or filled with whitespace.") - # The tag name is a number of some kind, we don't allow that. - elif is_number(tag_name): - log.warning(f"{ctx.author} tried to create a tag with a digit as its name. " - "Rejecting the request.") - raise BadArgument("Tag names can't be numbers.") - # The tag name is longer than 127 characters. elif len(tag_name) > 127: log.warning(f"{ctx.author} tried to request a tag name with over 127 characters. " -- cgit v1.2.3 From c0d2f51f4e4a57da23f1387e8cf6b1a9e8c02e73 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 26 Feb 2020 19:24:52 +0100 Subject: Adjust tests for new converter behavior. --- tests/bot/test_converters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py index b2b78d9dd..1e5ca62ae 100644 --- a/tests/bot/test_converters.py +++ b/tests/bot/test_converters.py @@ -68,7 +68,7 @@ class ConverterTests(unittest.TestCase): ('👋', "Don't be ridiculous, you can't use that character!"), ('', "Tag names should not be empty, or filled with whitespace."), (' ', "Tag names should not be empty, or filled with whitespace."), - ('42', "Tag names can't be numbers."), + ('42', "Tag names must contain at least one letter."), ('x' * 128, "Are you insane? That's way too long!"), ) -- cgit v1.2.3 From 43597788aef30381924efe8298aaa6c15f8d33f9 Mon Sep 17 00:00:00 2001 From: Joseph Date: Wed, 26 Feb 2020 19:32:34 +0000 Subject: Disable TRACE logging for Sentry breadcrumbs. --- bot/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/__main__.py b/bot/__main__.py index 490163739..a3f1855b5 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -10,7 +10,7 @@ from bot.bot import Bot from bot.constants import Bot as BotConfig, DEBUG_MODE sentry_logging = LoggingIntegration( - level=logging.TRACE, + level=logging.DEBUG, event_level=logging.WARNING ) -- cgit v1.2.3 From 5ae3949899bf8f5d637f7f5025311342012f6db6 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 26 Feb 2020 20:36:36 +0100 Subject: Remove logging from tag converters. --- bot/converters.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 745ce5b5d..1945e1da3 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -145,26 +145,18 @@ class TagNameConverter(Converter): # The tag name has at least one invalid character. if ascii(tag_name)[1:-1] != tag_name: - log.warning(f"{ctx.author} tried to put an invalid character in a tag name. " - "Rejecting the request.") raise BadArgument("Don't be ridiculous, you can't use that character!") # The tag name is either empty, or consists of nothing but whitespace. elif not tag_name: - log.warning(f"{ctx.author} tried to create a tag with a name consisting only of whitespace. " - "Rejecting the request.") raise BadArgument("Tag names should not be empty, or filled with whitespace.") # The tag name is longer than 127 characters. elif len(tag_name) > 127: - log.warning(f"{ctx.author} tried to request a tag name with over 127 characters. " - "Rejecting the request.") raise BadArgument("Are you insane? That's way too long!") # The tag name is ascii but does not contain any letters. elif not any(character.isalpha() for character in tag_name): - log.warning(f"{ctx.author} tried to request a tag name without letters. " - "Rejecting the request.") raise BadArgument("Tag names must contain at least one letter.") return tag_name @@ -184,8 +176,6 @@ class TagContentConverter(Converter): # The tag contents should not be empty, or filled with whitespace. if not tag_content: - log.warning(f"{ctx.author} tried to create a tag containing only whitespace. " - "Rejecting the request.") raise BadArgument("Tag contents should not be empty, or filled with whitespace.") return tag_content -- cgit v1.2.3 From 97b07a8be526b62db0b1b072b4d9773bff7a8db1 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 26 Feb 2020 21:58:08 +0100 Subject: Log invalid tag names in the error handler tag fallback. --- bot/cogs/error_handler.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 3486a746c..fff1f8c9f 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -89,12 +89,20 @@ class ErrorHandler(Cog): return # Return to not raise the exception - with contextlib.suppress(BadArgument, ResponseCodeError): - await ctx.invoke( - tags_get_command, - tag_name=await TagNameConverter.convert(ctx, ctx.invoked_with) + try: + tag_name = await TagNameConverter.convert(ctx, ctx.invoked_with) + except BadArgument: + log.debug( + f"{ctx.author} tried to use an invalid command " + f"and the fallback tag failed validation in TagNameConverter." ) - return + else: + with contextlib.suppress(ResponseCodeError): + await ctx.invoke( + tags_get_command, + tag_name=tag_name + ) + return elif isinstance(e, BadArgument): await ctx.send(f"Bad argument: {e}\n") await ctx.invoke(*help_command) -- cgit v1.2.3 From ded5c543d6b7abcb9789cd3c8f097a5649270c75 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Wed, 26 Feb 2020 16:04:17 -0500 Subject: Add clarifying comment to role checking logic implementation --- bot/cogs/antimalware.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index 957c458c0..9e9e81364 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -23,6 +23,7 @@ class AntiMalware(Cog): 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 -- cgit v1.2.3 From f87f7559db8b352490324a535fb77e88f2f68b41 Mon Sep 17 00:00:00 2001 From: Matteo Date: Thu, 27 Feb 2020 11:37:02 +0100 Subject: Split the eval command procedure into two functions. Two functions were created: send_eval and continue_eval, in order to facilitate testing. The corresponding tests are also changed in this commit. --- bot/cogs/snekbox.py | 112 +++++++++++++++++------------- tests/bot/cogs/test_snekbox.py | 150 ++++++++++++++++++++++------------------- 2 files changed, 148 insertions(+), 114 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index efa4696b5..25b2455e8 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -179,6 +179,68 @@ class Snekbox(Cog): 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```py\n{output}\n```" + if paste_link: + msg = f"{msg}\nFull output: {paste_link}" + + 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) -> Tuple[bool, Optional[str]]: + """ + Check if the eval session should continue. + + First item of the returned tuple is if the eval session should continue, + the second is the new code to evaluate. + """ + _predicate_eval_message_edit = partial(predicate_eval_message_edit, ctx) + _predicate_emoji_reaction = partial(predicate_eval_emoji_reaction, ctx) + + try: + _, new_message = await self.bot.wait_for( + 'message_edit', + check=_predicate_eval_message_edit, + timeout=10 + ) + await ctx.message.add_reaction('🔁') + await self.bot.wait_for( + 'reaction_add', + check=_predicate_emoji_reaction, + timeout=10 + ) + + code = new_message.content.split(' ', maxsplit=1)[1] + await ctx.message.clear_reactions() + with contextlib.suppress(HTTPException): + await response.delete() + + except asyncio.TimeoutError: + await ctx.message.clear_reactions() + return False, None + + return True, code + @command(name="eval", aliases=("e",)) @guild_only() @in_channel(Channels.bot, hidden_channels=(Channels.esoteric,), bypass_roles=EVAL_ROLES) @@ -203,58 +265,18 @@ class Snekbox(Cog): log.info(f"Received code from {ctx.author} for evaluation:\n{code}") - _predicate_eval_message_edit = partial(predicate_eval_message_edit, ctx) - _predicate_emoji_reaction = partial(predicate_eval_emoji_reaction, ctx) - while True: self.jobs[ctx.author.id] = datetime.datetime.now() code = self.prepare_input(code) - try: - 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```py\n{output}\n```" - if paste_link: - msg = f"{msg}\nFull output: {paste_link}" - - 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']}") + response = await self.send_eval(ctx, code) finally: del self.jobs[ctx.author.id] - try: - _, new_message = await self.bot.wait_for( - 'message_edit', - check=_predicate_eval_message_edit, - timeout=10 - ) - await ctx.message.add_reaction('🔁') - await self.bot.wait_for( - 'reaction_add', - check=_predicate_emoji_reaction, - timeout=10 - ) - - log.info(f"Re-evaluating message {ctx.message.id}") - code = new_message.content.split(' ', maxsplit=1)[1] - await ctx.message.clear_reactions() - with contextlib.suppress(HTTPException): - await response.delete() - except asyncio.TimeoutError: - await ctx.message.clear_reactions() - return + continue_eval, code = await self.continue_eval(ctx, response) + if not continue_eval: + break + log.info(f"Re-evaluating message {ctx.message.id}") def predicate_eval_message_edit(ctx: Context, old_msg: Message, new_msg: Message) -> bool: diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 112c923c8..c1c0f8d47 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -1,6 +1,7 @@ import asyncio import logging import unittest +from functools import partial from unittest.mock import MagicMock, Mock, call, patch from bot.cogs import snekbox @@ -175,28 +176,33 @@ class SnekboxTests(unittest.TestCase): async def test_eval_command_evaluate_once(self): """Test the eval command procedure.""" ctx = MockContext() - ctx.message = MockMessage() - ctx.send = AsyncMock() - ctx.author.mention = '@LemonLemonishBeard#0042' - ctx.typing = MagicMock(return_value=AsyncContextManagerMock(None)) - self.cog.post_eval = AsyncMock(return_value={'stdout': '', 'returncode': 0}) - self.cog.get_results_message = MagicMock(return_value=('Return code 0', '')) - self.cog.get_status_emoji = MagicMock(return_value=':yay!:') - self.cog.format_output = AsyncMock(return_value=('[No output]', None)) - self.bot.wait_for.side_effect = asyncio.TimeoutError + response = MockMessage() + self.cog.prepare_input = MagicMock(return_value='MyAwesomeFormattedCode') + self.cog.send_eval = AsyncMock(return_value=response) + self.cog.continue_eval = AsyncMock(return_value=(False, None)) await self.cog.eval_command.callback(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) - ctx.send.assert_called_once_with( - '@LemonLemonishBeard#0042 :yay!: Return code 0.\n\n```py\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}) - self.cog.get_results_message.assert_called_once_with({'stdout': '', 'returncode': 0}) - self.cog.format_output.assert_called_once_with('') + @async_test + async def test_eval_command_evaluate_twice(self): + """Test the eval and re-eval command procedure.""" + ctx = MockContext() + response = MockMessage() + self.cog.prepare_input = MagicMock(return_value='MyAwesomeFormattedCode') + self.cog.send_eval = AsyncMock(return_value=response) + self.cog.continue_eval = AsyncMock() + self.cog.continue_eval.side_effect = ((True, 'MyAwesomeCode-2'), (False, None)) + + await self.cog.eval_command.callback(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) @async_test - async def test_eval_command_reject_two_eval(self): + async def test_eval_command_reject_two_eval_at_the_same_time(self): """Test if the eval command rejects an eval if the author already have a running eval.""" ctx = MockContext() ctx.author.id = 42 @@ -217,92 +223,98 @@ class SnekboxTests(unittest.TestCase): ctx.invoke.assert_called_once_with(self.bot.get_command("help"), "eval") @async_test - async def test_eval_command_return_error(self): - """Test the eval command error handling.""" + async def test_send_eval(self): + """Test the send_eval function.""" ctx = MockContext() ctx.message = MockMessage() ctx.send = AsyncMock() ctx.author.mention = '@LemonLemonishBeard#0042' ctx.typing = MagicMock(return_value=AsyncContextManagerMock(None)) - self.cog.post_eval = AsyncMock(return_value={'stdout': 'ERROR', 'returncode': 127}) - self.cog.get_results_message = MagicMock(return_value=('Return code 127', 'Error occurred')) - self.cog.get_status_emoji = MagicMock(return_value=':nope!:') - self.cog.format_output = AsyncMock() - self.bot.wait_for.side_effect = asyncio.TimeoutError - - await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') + self.cog.post_eval = AsyncMock(return_value={'stdout': '', 'returncode': 0}) + self.cog.get_results_message = MagicMock(return_value=('Return code 0', '')) + self.cog.get_status_emoji = MagicMock(return_value=':yay!:') + self.cog.format_output = AsyncMock(return_value=('[No output]', None)) + await self.cog.send_eval(ctx, 'MyAwesomeCode') ctx.send.assert_called_once_with( - '@LemonLemonishBeard#0042 :nope!: Return code 127.\n\n```py\nError occurred\n```' + '@LemonLemonishBeard#0042 :yay!: Return code 0.\n\n```py\n[No output]\n```' ) self.cog.post_eval.assert_called_once_with('MyAwesomeCode') - self.cog.get_results_message.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127}) - self.cog.get_status_emoji.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127}) - self.cog.format_output.assert_not_called() + self.cog.get_status_emoji.assert_called_once_with({'stdout': '', 'returncode': 0}) + self.cog.get_results_message.assert_called_once_with({'stdout': '', 'returncode': 0}) + self.cog.format_output.assert_called_once_with('') @async_test - async def test_eval_command_with_paste_link(self): - """Test the eval command procedure with the use of a paste link.""" + async def test_send_eval_with_paste_link(self): + """Test the send_eval function with a too long output that generate a paste link.""" ctx = MockContext() ctx.message = MockMessage() ctx.send = AsyncMock() ctx.author.mention = '@LemonLemonishBeard#0042' ctx.typing = MagicMock(return_value=AsyncContextManagerMock(None)) - self.cog.post_eval = AsyncMock(return_value={'stdout': 'SuperLongBeard', 'returncode': 0}) + self.cog.post_eval = AsyncMock(return_value={'stdout': 'Way too long beard', 'returncode': 0}) self.cog.get_results_message = MagicMock(return_value=('Return code 0', '')) self.cog.get_status_emoji = MagicMock(return_value=':yay!:') - self.cog.format_output = AsyncMock(return_value=('Truncated - too long beard', 'https://testificate.com/')) - self.bot.wait_for.side_effect = asyncio.TimeoutError - - await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') + self.cog.format_output = AsyncMock(return_value=('Way too long beard', 'lookatmybeard.com')) + await self.cog.send_eval(ctx, 'MyAwesomeCode') ctx.send.assert_called_once_with( - '@LemonLemonishBeard#0042 :yay!: Return code 0.\n\n```py\n' - 'Truncated - too long beard\n```\nFull output: https://testificate.com/' + '@LemonLemonishBeard#0042 :yay!: Return code 0.' + '\n\n```py\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': 'SuperLongBeard', 'returncode': 0}) - self.cog.get_results_message.assert_called_once_with({'stdout': 'SuperLongBeard', 'returncode': 0}) - self.cog.format_output.assert_called_with('SuperLongBeard') + self.cog.get_status_emoji.assert_called_once_with({'stdout': 'Way too long beard', 'returncode': 0}) + self.cog.get_results_message.assert_called_once_with({'stdout': 'Way too long beard', 'returncode': 0}) + self.cog.format_output.assert_called_once_with('Way too long beard') @async_test - async def test_eval_command_evaluate_twice(self): - """Test the eval command re-evaluation procedure.""" + async def test_send_eval_with_non_zero_eval(self): + """Test the send_eval function with a code returning a non-zero code.""" ctx = MockContext() ctx.message = MockMessage() - ctx.message.content = '!e MyAwesomeCode' - updated_msg = MockMessage() - updated_msg .content = '!e MyAwesomeCode-2' - response_msg = MockMessage() - response_msg.delete = AsyncMock() - ctx.send = AsyncMock(return_value=response_msg) + ctx.send = AsyncMock() ctx.author.mention = '@LemonLemonishBeard#0042' ctx.typing = MagicMock(return_value=AsyncContextManagerMock(None)) - self.cog.post_eval = AsyncMock(return_value={'stdout': '', 'returncode': 0}) - self.cog.get_results_message = MagicMock(return_value=('Return code 0', '')) - self.cog.get_status_emoji = MagicMock(return_value=':yay!:') - self.cog.format_output = AsyncMock(return_value=('[No output]', None)) - self.bot.wait_for.side_effect = ((None, updated_msg), None, asyncio.TimeoutError) - - await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') - - self.cog.post_eval.assert_has_calls((call('MyAwesomeCode'), call('MyAwesomeCode-2'))) + self.cog.post_eval = AsyncMock(return_value={'stdout': 'ERROR', 'returncode': 127}) + self.cog.get_results_message = MagicMock(return_value=('Return code 127', 'Beard got stuck in the eval')) + self.cog.get_status_emoji = MagicMock(return_value=':nope!:') + self.cog.format_output = AsyncMock() # This function isn't called - # Multiplied by 2 because we expect it to be called twice - ctx.send.assert_has_calls( - [call('@LemonLemonishBeard#0042 :yay!: Return code 0.\n\n```py\n[No output]\n```')] * 2 + 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```' ) - self.cog.get_status_emoji.assert_has_calls([call({'stdout': '', 'returncode': 0})] * 2) - self.cog.get_results_message.assert_has_calls([call({'stdout': '', 'returncode': 0})] * 2) - self.cog.format_output.assert_has_calls([call('')] * 2) + self.cog.post_eval.assert_called_once_with('MyAwesomeCode') + self.cog.get_status_emoji.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127}) + self.cog.get_results_message.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127}) + self.cog.format_output.assert_not_called() + @async_test + async def test_continue_eval_does_continue(self): + """Test that the continue_eval function does continue if required conditions are met.""" + ctx = MockContext(message=MockMessage(add_reaction=AsyncMock(), clear_reactions=AsyncMock())) + response = MockMessage(delete=AsyncMock()) + new_msg = MockMessage(content='!e NewCode') + self.bot.wait_for.side_effect = ((None, new_msg), None) + + actual = await self.cog.continue_eval(ctx, response) + self.assertEqual(actual, (True, 'NewCode')) self.bot.wait_for.has_calls( - call('message_edit', check=snekbox.predicate_eval_message_edit, timeout=10), - call('reaction_add', check=snekbox.predicate_eval_emoji_reaction, timeout=10) + call('message_edit', partial(snekbox.predicate_eval_message_edit, ctx), timeout=10), + call('reaction_add', partial(snekbox.predicate_eval_emoji_reaction, ctx), timeout=10) ) ctx.message.add_reaction.assert_called_once_with('🔁') - ctx.message.clear_reactions.assert_called() - response_msg.delete.assert_called_once() + ctx.message.clear_reactions.assert_called_once() + response.delete.assert_called_once() + + @async_test + async def test_continue_eval_does_not_continue(self): + ctx = MockContext(message=MockMessage(clear_reactions=AsyncMock())) + self.bot.wait_for.side_effect = asyncio.TimeoutError + + actual = await self.cog.continue_eval(ctx, MockMessage()) + self.assertEqual(actual, (False, None)) + ctx.message.clear_reactions.assert_called_once() def test_predicate_eval_message_edit(self): """Test the predicate_eval_message_edit function.""" -- cgit v1.2.3 From afc74faadd5fb4d3fd3003bed9f2a1f241c0dc58 Mon Sep 17 00:00:00 2001 From: Matteo Date: Thu, 27 Feb 2020 11:47:01 +0100 Subject: Use unicode code point instead of literal for the snekbox re-eval emoji Unicode literals aren't really safe compared to code points --- bot/cogs/snekbox.py | 8 +++++--- tests/bot/cogs/test_snekbox.py | 6 +++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 25b2455e8..52d830fa8 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -42,6 +42,8 @@ EVAL_ROLES = (Roles.helpers, Roles.moderator, Roles.admin, Roles.owner, Roles.ro SIGKILL = 9 +REEVAL_EMOJI = '\U0001f501' # :repeat: + class Snekbox(Cog): """Safe evaluation of Python code using Snekbox.""" @@ -223,7 +225,7 @@ class Snekbox(Cog): check=_predicate_eval_message_edit, timeout=10 ) - await ctx.message.add_reaction('🔁') + await ctx.message.add_reaction(REEVAL_EMOJI) await self.bot.wait_for( 'reaction_add', check=_predicate_emoji_reaction, @@ -285,8 +287,8 @@ def predicate_eval_message_edit(ctx: Context, old_msg: Message, new_msg: Message def predicate_eval_emoji_reaction(ctx: Context, reaction: Reaction, user: User) -> bool: - """Return True if the reaction 🔁 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) == '🔁' + """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: diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index c1c0f8d47..e7a1e3362 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -303,7 +303,7 @@ class SnekboxTests(unittest.TestCase): call('message_edit', partial(snekbox.predicate_eval_message_edit, ctx), timeout=10), call('reaction_add', partial(snekbox.predicate_eval_emoji_reaction, ctx), timeout=10) ) - ctx.message.add_reaction.assert_called_once_with('🔁') + ctx.message.add_reaction.assert_called_once_with(snekbox.REEVAL_EMOJI) ctx.message.clear_reactions.assert_called_once() response.delete.assert_called_once() @@ -336,12 +336,12 @@ class SnekboxTests(unittest.TestCase): def test_predicate_eval_emoji_reaction(self): """Test the predicate_eval_emoji_reaction function.""" valid_reaction = MockReaction(message=MockMessage(id=1)) - valid_reaction.__str__.return_value = '🔁' + valid_reaction.__str__.return_value = snekbox.REEVAL_EMOJI valid_ctx = MockContext(message=MockMessage(id=1), author=MockUser(id=2)) valid_user = MockUser(id=2) invalid_reaction_id = MockReaction(message=MockMessage(id=42)) - invalid_reaction_id.__str__.return_value = '🔁' + invalid_reaction_id.__str__.return_value = snekbox.REEVAL_EMOJI invalid_user_id = MockUser(id=42) invalid_reaction_str = MockReaction(message=MockMessage(id=1)) invalid_reaction_str.__str__.return_value = ':longbeard:' -- cgit v1.2.3 From 671052ca7862fd75c38e5f5162ce0dc4ded8531b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 27 Feb 2020 08:39:50 -0800 Subject: Moderation: fix task cancellation for permanent infraction when editing A task should not be cancelled if an infraction is permanent because tasks don't exist for permanent infractions. Fixes BOT-1V --- bot/cogs/moderation/management.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index f2964cd78..f74089056 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -129,7 +129,9 @@ class ModManagement(commands.Cog): # Re-schedule infraction if the expiration has been updated if 'expires_at' in request_data: - self.infractions_cog.cancel_task(new_infraction['id']) + # 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']) # If the infraction was not marked as permanent, schedule a new expiration task if request_data['expires_at']: -- cgit v1.2.3 From 848a613dee4bcbb00226fb98fc6501403dc0d7c7 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 27 Feb 2020 18:53:44 +0100 Subject: Remove unnecessary newlines from call. --- bot/cogs/error_handler.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index fff1f8c9f..0d4604430 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -98,10 +98,7 @@ class ErrorHandler(Cog): ) else: with contextlib.suppress(ResponseCodeError): - await ctx.invoke( - tags_get_command, - tag_name=tag_name - ) + await ctx.invoke(tags_get_command, tag_name=tag_name) return elif isinstance(e, BadArgument): await ctx.send(f"Bad argument: {e}\n") -- cgit v1.2.3 From b62e15f835ff4b6c808a9b571919bcfa479d004b Mon Sep 17 00:00:00 2001 From: Matteo Date: Fri, 28 Feb 2020 09:41:12 +0100 Subject: Return only the new code in continue_eval and check for truthiness instead --- bot/cogs/snekbox.py | 13 ++++++------- tests/bot/cogs/test_snekbox.py | 8 ++++---- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 52d830fa8..381b309e0 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -209,12 +209,11 @@ class Snekbox(Cog): 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) -> Tuple[bool, Optional[str]]: + async def continue_eval(self, ctx: Context, response: Message) -> Optional[str]: """ Check if the eval session should continue. - First item of the returned tuple is if the eval session should continue, - the second is the new code to evaluate. + 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) @@ -239,9 +238,9 @@ class Snekbox(Cog): except asyncio.TimeoutError: await ctx.message.clear_reactions() - return False, None + return None - return True, code + return code @command(name="eval", aliases=("e",)) @guild_only() @@ -275,8 +274,8 @@ class Snekbox(Cog): finally: del self.jobs[ctx.author.id] - continue_eval, code = await self.continue_eval(ctx, response) - if not continue_eval: + code = await self.continue_eval(ctx, response) + if not code: break log.info(f"Re-evaluating message {ctx.message.id}") diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index e7a1e3362..985bc66a1 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -179,7 +179,7 @@ class SnekboxTests(unittest.TestCase): response = MockMessage() self.cog.prepare_input = MagicMock(return_value='MyAwesomeFormattedCode') self.cog.send_eval = AsyncMock(return_value=response) - self.cog.continue_eval = AsyncMock(return_value=(False, None)) + self.cog.continue_eval = AsyncMock(return_value=None) await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') self.cog.prepare_input.assert_called_once_with('MyAwesomeCode') @@ -194,7 +194,7 @@ class SnekboxTests(unittest.TestCase): self.cog.prepare_input = MagicMock(return_value='MyAwesomeFormattedCode') self.cog.send_eval = AsyncMock(return_value=response) self.cog.continue_eval = AsyncMock() - self.cog.continue_eval.side_effect = ((True, 'MyAwesomeCode-2'), (False, None)) + self.cog.continue_eval.side_effect = ('MyAwesomeCode-2', None) await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') self.cog.prepare_input.has_calls(call('MyAwesomeCode'), call('MyAwesomeCode-2')) @@ -298,7 +298,7 @@ class SnekboxTests(unittest.TestCase): self.bot.wait_for.side_effect = ((None, new_msg), None) actual = await self.cog.continue_eval(ctx, response) - self.assertEqual(actual, (True, 'NewCode')) + self.assertEqual(actual, 'NewCode') self.bot.wait_for.has_calls( call('message_edit', partial(snekbox.predicate_eval_message_edit, ctx), timeout=10), call('reaction_add', partial(snekbox.predicate_eval_emoji_reaction, ctx), timeout=10) @@ -313,7 +313,7 @@ class SnekboxTests(unittest.TestCase): self.bot.wait_for.side_effect = asyncio.TimeoutError actual = await self.cog.continue_eval(ctx, MockMessage()) - self.assertEqual(actual, (False, None)) + self.assertEqual(actual, None) ctx.message.clear_reactions.assert_called_once() def test_predicate_eval_message_edit(self): -- cgit v1.2.3 From 77ee577598ae0ed9d49a0771f682e5d5a96fc7e5 Mon Sep 17 00:00:00 2001 From: Matteo Date: Fri, 28 Feb 2020 09:46:51 +0100 Subject: Ignore NotFound errors inside continue_eval It could have caused some errors if the user delete his own message --- bot/cogs/snekbox.py | 49 +++++++++++++++++++++++++------------------------ 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 381b309e0..d52027ac6 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -8,7 +8,7 @@ from functools import partial from signal import Signals from typing import Optional, Tuple -from discord import HTTPException, Message, Reaction, User +from discord import HTTPException, Message, NotFound, Reaction, User from discord.ext.commands import Cog, Context, command, guild_only from bot.bot import Bot @@ -218,29 +218,30 @@ class Snekbox(Cog): _predicate_eval_message_edit = partial(predicate_eval_message_edit, ctx) _predicate_emoji_reaction = partial(predicate_eval_emoji_reaction, ctx) - try: - _, new_message = await self.bot.wait_for( - 'message_edit', - check=_predicate_eval_message_edit, - timeout=10 - ) - await ctx.message.add_reaction(REEVAL_EMOJI) - await self.bot.wait_for( - 'reaction_add', - check=_predicate_emoji_reaction, - timeout=10 - ) - - code = new_message.content.split(' ', maxsplit=1)[1] - await ctx.message.clear_reactions() - with contextlib.suppress(HTTPException): - await response.delete() - - except asyncio.TimeoutError: - await ctx.message.clear_reactions() - return None - - return code + with contextlib.suppress(NotFound): + try: + _, new_message = await self.bot.wait_for( + 'message_edit', + check=_predicate_eval_message_edit, + timeout=10 + ) + await ctx.message.add_reaction(REEVAL_EMOJI) + await self.bot.wait_for( + 'reaction_add', + check=_predicate_emoji_reaction, + timeout=10 + ) + + code = new_message.content.split(' ', maxsplit=1)[1] + await ctx.message.clear_reactions() + with contextlib.suppress(HTTPException): + await response.delete() + + except asyncio.TimeoutError: + await ctx.message.clear_reactions() + return None + + return code @command(name="eval", aliases=("e",)) @guild_only() -- cgit v1.2.3 From b8769e036e9247be47cea1b2073e92ea48724ca8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 28 Feb 2020 09:41:37 -0800 Subject: Snekbox: mention re-evaluation feature in the command's docstring --- bot/cogs/snekbox.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 44764e7e9..cff7c5786 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -251,7 +251,10 @@ class Snekbox(Cog): Run Python code and get the results. This command supports multiple lines of code, including code wrapped inside a formatted code - block. We've done our best to make this safe, but do let us know if you manage to find an + 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: -- cgit v1.2.3